diff --git a/.flake8 b/.flake8 deleted file mode 100644 index 1ba2e1b..0000000 --- a/.flake8 +++ /dev/null @@ -1,22 +0,0 @@ -[flake8] -# required by black, https://github.com/psf/black/blob/master/.flake8 -max-line-length = 88 -max-complexity = 18 -ignore = E203, E266, E501, W503, F403, F401 -select = B,C,E,F,W,T4,B9 -per-file-ignores = - __init__.py:F401 -exclude = - .git, - __pycache__, - setup.py, - build, - dist, - releases, - .venv, - .tox, - .mypy_cache, - .pytest_cache, - .vscode, - .github, - tests diff --git a/.github/workflows/dev.yml b/.github/workflows/dev.yml index 5bf5350..4bea65e 100644 --- a/.github/workflows/dev.yml +++ b/.github/workflows/dev.yml @@ -27,8 +27,8 @@ jobs: # Steps represent a sequence of tasks that will be executed as part of the job steps: # Checks-out your repository under $GITHUB_WORKSPACE, so your job can access it - - uses: actions/checkout@v3 - - uses: actions/setup-python@v4 + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 with: python-version: ${{ matrix.python-versions }} @@ -54,8 +54,8 @@ jobs: needs: test runs-on: ubuntu-latest steps: - - uses: actions/checkout@v3 - - uses: actions/setup-python@v4 + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 with: python-version: '3.10' diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index a549f00..227272e 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -27,7 +27,7 @@ jobs: # Steps represent a sequence of tasks that will be executed as part of the job steps: # Checks-out your repository under $GITHUB_WORKSPACE, so your job can access it - - uses: actions/checkout@v3 + - uses: actions/checkout@v4 - name: Generate Changelog if: ${{ false }} @@ -42,7 +42,7 @@ jobs: addSections: '{"documentation":{"prefix":"**Documentation:**","labels":["documentation"]}}' output: CHANGELOG.md - - uses: actions/setup-python@v4 + - uses: actions/setup-python@v5 with: python-version: ${{ matrix.python-versions }} diff --git a/.isort.cfg b/.isort.cfg deleted file mode 100644 index 0b49bd0..0000000 --- a/.isort.cfg +++ /dev/null @@ -1,9 +0,0 @@ -[settings] -multi_line_output = 3 -include_trailing_comma = True -force_grid_wrap = 0 -use_parentheses = True -ensure_newline_before_comments = True -line_length = 88 -# you can skip files as below -#skip_glob = docs/conf.py diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index cf45518..41a0161 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -1,94 +1,34 @@ repos: -- repo: https://github.com/Lucas-C/pre-commit-hooks - rev: v1.5.4 - hooks: - - id: forbid-crlf - - id: remove-crlf - - id: forbid-tabs - - id: remove-tabs - repo: https://github.com/pre-commit/pre-commit-hooks rev: v4.5.0 hooks: - - id: debug-statements - - id: mixed-line-ending - - id: name-tests-test - args: [--pytest-test-first] - id: trailing-whitespace - - id: end-of-file-fixer + args: [--markdown-linebreak-ext=md] - id: check-merge-conflict - id: check-yaml args: [--unsafe] -- repo: https://github.com/pre-commit/pygrep-hooks - rev: v1.10.0 - hooks: - - id: python-check-blanket-noqa - - id: python-check-blanket-type-ignore - - id: python-no-eval - - id: python-use-type-annotations - - id: text-unicode-replacement-char -- repo: https://github.com/Lucas-C/pre-commit-hooks-safety - rev: v1.3.2 + - id: no-commit-to-branch +- repo: https://github.com/pre-commit/pygrep-hooks + rev: v1.10.0 hooks: - - id: python-safety-dependencies-check - files: pyproject.toml -- repo: https://github.com/executablebooks/mdformat - rev: 0.7.17 - hooks: - - id: mdformat - args: - - --number - additional_dependencies: - - mdformat-black + - id: python-use-type-annotations + - id: text-unicode-replacement-char - repo: https://github.com/myint/docformatter rev: v1.7.5 hooks: - id: docformatter + additional_dependencies: + - tomli args: - --in-place - - --wrap-summaries=100 - - --wrap-descriptions=100 -- repo: https://github.com/hadialqattan/pycln - rev: v2.4.0 - hooks: - - id: pycln - args: [--config=pyproject.toml] -- repo: https://github.com/frostming/fix-future-annotations - rev: 0.5.0 - hooks: - - id: fix-future-annotations -- repo: https://github.com/asottile/pyupgrade - rev: v3.15.0 - hooks: - - id: pyupgrade - args: [--py38-plus] -- repo: https://github.com/pre-commit/mirrors-isort - rev: v5.10.1 +- repo: https://github.com/astral-sh/ruff-pre-commit + rev: v0.2.1 hooks: - - id: isort -- repo: https://github.com/ambv/black - rev: 23.11.0 - hooks: - - id: black -- repo: https://github.com/PyCQA/flake8 - rev: 6.1.0 - hooks: - - id: flake8 - additional_dependencies: [flake8-typing-imports==1.14.0] -- repo: https://github.com/asottile/blacken-docs - rev: 1.16.0 - hooks: - - id: blacken-docs -- repo: https://github.com/pycqa/pydocstyle - rev: 6.3.0 - hooks: - - id: pydocstyle - args: - - --add-ignore=D107 - additional_dependencies: - - toml - exclude: 'tests/' + - id: ruff + args: [ --fix ] + - id: ruff-format - repo: https://github.com/pre-commit/mirrors-mypy - rev: v1.7.1 + rev: v1.8.0 hooks: - id: mypy args: @@ -99,8 +39,9 @@ repos: - --show-column-numbers additional_dependencies: - pytest-mypy - - jwskate>=0.9.0 + - jwskate>=0.11.1 - types-requests - requests_mock - flask - freezegun + - furl diff --git a/README.md b/README.md index 25ae6ba..14b1192 100644 --- a/README.md +++ b/README.md @@ -17,7 +17,7 @@ grant types are easy to add if needed. It also supports [OpenID Connect 1.0](https://openid.net/specs/openid-connect-core-1_0.html), [PKCE](https://www.rfc-editor.org/rfc/rfc7636.html), [Client Assertions](https://www.rfc-editor.org/rfc/rfc7523.html#section-2.2), -[Token Revocation](https://www.rfc-editor.org/rfc/rfc7009.html), and +[Token Revocation](https://www.rfc-editor.org/rfc/rfc7009.html) and [Introspection](https://www.rfc-editor.org/rfc/rfc7662.html), [Resource Indicators](https://www.rfc-editor.org/rfc/rfc8707.html), [JWT-secured Authorization Requests](https://datatracker.ietf.org/doc/rfc9101/), @@ -32,12 +32,14 @@ Please note that despite the name, this library has no relationship with Google [oauth2client](https://github.com/googleapis/oauth2client) library. [![made-with-python](https://img.shields.io/badge/Made%20with-Python-1f425f.svg)](https://www.python.org/) -[![Downloads](https://pepy.tech/badge/requests_oauth2client/month)](https://pepy.tech/project/requests_oauth2client) +[![PyPi version](https://img.shields.io/pypi/v/black)](https://pypi.org/project/black/) +[![Downloads](https://static.pepy.tech/badge/requests_oauth2client/month)](https://pepy.tech/project/requests_oauth2client) [![Supported Versions](https://img.shields.io/pypi/pyversions/requests_oauth2client.svg)](https://pypi.org/project/requests_oauth2client) [![PyPi license](https://badgen.net/pypi/license/requests_oauth2client/)](https://pypi.com/project/requests_oauth2client/) [![PyPI status](https://img.shields.io/pypi/status/requests_oauth2client.svg)](https://pypi.python.org/pypi/requests_oauth2client/) [![GitHub commits](https://badgen.net/github/commits/guillp/requests_oauth2client)](https://github.com/guillp/requests_oauth2client/commit/) [![GitHub latest commit](https://badgen.net/github/last-commit/guillp/requests_oauth2client)](https://github.com/guillp/requests_oauth2client/commit/) +[![Ruff](https://img.shields.io/endpoint?url=https://raw.githubusercontent.com/astral-sh/ruff/main/assets/badge/v2.json)](https://github.com/astral-sh/ruff) # Documentation @@ -76,8 +78,8 @@ token = "an_access_token" resp = requests.get("https://my.protected.api/endpoint", auth=BearerAuth(token)) ``` -This authentication handler will add a properly formatted `Authorization: Bearer ` header in the request, -with your access token according to [RFC6750](https://datatracker.ietf.org/doc/html/rfc6750#section-2.1). +This authentication handler will add a `Authorization: Bearer ` header in the request, +with your access token, properly formatted according to [RFC6750](https://datatracker.ietf.org/doc/html/rfc6750#section-2.1). ## Using an OAuth2Client @@ -88,8 +90,8 @@ BackChannel Authentication and Device Authorization Endpoints. You have to provide the URLs for those endpoints if you intend to use them. Otherwise, only the Token Endpoint is mandatory to initialize an `OAuth2Client`. -To initialize an [OAuth2Client], you only need a Token Endpoint URI from your AS, and the credentials for your -application, which are often a `client_id` and a `client_secret`, usually also provided by the AS: +To initialize an instance of `OAuth2Client`, you only need a Token Endpoint URI from your AS, and the credentials for your +application, which are typically a `client_id` and a `client_secret`, usually also provided by the AS: ```python from requests_oauth2client import OAuth2Client @@ -108,6 +110,8 @@ default authentication method used by `OAuth2Client` is *Client Secret Post*, bu *Client Secret Basic*, *Client Secret JWT* or *Private Key JWT* are supported as well. See [more about client authentication methods below](#supported-client-authentication-methods). +Instead of providing each endpoint URL yourself, you may also [use the AS metadata endpoint URI](#initializing-an-oauth2client-from-a-discovery-document), or the document data itself, to initialize your OAuth 2.0 client with the appropriate endpoints. + ## Obtaining tokens [OAuth2Client] has dedicated methods to send requests to the Token Endpoint using the different standardised (and/or @@ -172,7 +176,8 @@ from requests_oauth2client import OAuth2Client oauth2client = OAuth2Client( token_endpoint="https://url.to.the/token_endpoint", - auth=("client_id", "client_secret"), + client_id="client_id", + client_secret="client_secret", ) token = oauth2client.client_credentials(scope="myscope") @@ -192,7 +197,7 @@ client level (but it might be required by your AS to serve your request). You can use the [OAuth2ClientCredentialsAuth](https://guillp.github.io/requests_oauth2client/api/#requests_oauth2client.auth.OAuth2ClientCredentialsAuth) -auth handler. It takes an [OAuth2Client] as parameter, and the additional kwargs to pass to the token endpoint: +auth handler. It takes an `OAuth2Client` as parameter, and the additional kwargs to pass to the token endpoint: ```python import requests @@ -200,7 +205,8 @@ from requests_oauth2client import OAuth2Client, OAuth2ClientCredentialsAuth oauth2client = OAuth2Client( token_endpoint="https://url.to.the/token_endpoint", - auth=("client_id", "client_secret"), + client_id="client_id", + client_secret="client_secret", ) auth = OAuth2ClientCredentialsAuth( @@ -225,9 +231,20 @@ will automatically retrieve an access token from the AS using the Client Credent request. Next requests will use the same token, as long as it is valid. A new token will be automatically retrieved once the previous one is expired. +You can configure a leeway, which is a period of time before the actual expiration, in seconds, when a new token will be +obtained. This may help getting continuous access to the API when the client and API clocks are slightly out of sync. Use the parameter `leeway` to `OAuth2ClientCredentialsAuth`: + +```python +auth = OAuth2ClientCredentialsAuth( + oauth2client, + scope="myscope", + leeway=30, +) +``` + ### Authorization Code Grant -Obtaining tokens with the Authorization code grant is made in 3 steps: +Obtaining tokens using the Authorization code grant is made in 3 steps: 1. your application must open a specific url called the *Authentication Request* in a browser. @@ -239,18 +256,19 @@ Obtaining tokens with the Authorization code grant is made in 3 steps: 3. your application must then exchange this Authorization Code for an *Access Token*, with a request to the Token Endpoint. -`requests_oauth2client`, and more specifically [OAuth2Client] will help you with all those steps, as described below. +Using an `OAuth2Client` will help you with all those steps, as described below. #### Generating Authorization Requests -To be able to use the Authorization Code grant, you need two urls: +To be able to use the Authorization Code grant, you need 2 (optionally 3) URIs: - the URL for Authorization Endpoint, which is the url where you must send your Authorization Requests - the Redirect URI, which is the url pointing to your application, where the Authorization Server will reply with Authorization Response +- optionally, the issuer identifier, if your AS uses [Issuer Identification](RFC9207). -You can declare those urls when initializing your OAuth2Client instance. Then you can generate valid Authorization -Requests by calling the method `.authorization_request()`: +You can declare those URIs when initializing your `OAuth2Client` instance, or you can [use the AS discovery endpoint](#initializing-an-oauth2client-from-a-discovery-document) to initialize those URLs automatically. +Then you can generate valid Authorization Requests by calling the method `.authorization_request()`, with the request specific parameters, such as `scope`, `state`, `nonce` as parameter: ```python from requests_oauth2client import OAuth2Client @@ -267,7 +285,7 @@ az_request = client.authorization_request(scope="openid email profile") print(az_request) # this will look like this, with line feeds for display purposes only: -# https://url.to.the/authorization_endpoint +# https://url.to.the.as/authorization_endpoint # ?client_id=client_id # &redirect_uri=https%3A%2F%2Furl.to.my.application%2Fredirect_uri # &response_type=code @@ -286,7 +304,7 @@ webbrowser.open(az_request.uri) Note that the `state`, `nonce` and `code_challenge` parameters are generated with secure random values by default. Should you wish to use your own values, you can pass them as parameters to `OAuth2Client.authorization_request()`. For PKCE, you need to pass your generated `code_verifier`, and the `code_challenge` will automatically be derived from it. -If you don't want to use PKCE, you can pass `code_challenge_method=None` when initializing your `OAuth2Client`. +If you want to disable PKCE, you can pass `code_challenge_method=None` when initializing your `OAuth2Client`. #### Validating the Authorization Response @@ -294,7 +312,7 @@ Once you have redirected the user browser to the Authorization Request URI, and authenticated and authorized, plus any other extra interactive step is complete, the AS will respond with a redirection to your redirect_uri. That is the *Authorization Response*. It contains several parameters that must be retrieved by your client. The *Authorization Code* is one of those parameters, but you must also validate that the *state* matches -your request. You can do this with: +your request; if using [AS Issuer Identification](RFC9207), you must also validate that the issuer matches what is expected. You can do this with: ```python # using the `az_request` as defined above @@ -302,10 +320,11 @@ your request. You can do this with: response_uri = input( "Please enter the full url and/or params obtained on the redirect_uri: " ) +# say the callback url is https://url.to.my.application/redirect_uri?code=an_az_code&state=FBx9mWeLwoKGgG76vhi6v61-4mgxmgZhtWIa7aTffdY&issuer=https://url.to.the.as az_response = az_request.validate_callback(response_uri) ``` -This auth_response is an `AuthorizationResponse` instance and contains everything that is needed for your application to +This `auth_response` is an `AuthorizationResponse` instance and contains everything that is needed for your application to complete the authentication and get its tokens from the AS. #### Exchanging code for tokens @@ -778,7 +797,7 @@ resp = oauth2client.userinfo("mytoken") It returns whatever data is returned by the userinfo endpoint (if it is a JSON, its content is returned decoded). -## Initializing an OAuth2Client from a discovery document +## Initializing an `OAuth2Client` from a discovery document You can initialize an [OAuth2Client] with the endpoint URIs mentioned in a standardised discovery document with the [OAuth2Client.from_discovery_endpoint()](https://guillp.github.io/requests_oauth2client/api/#requests_oauth2client.client.OAuth2Client.from_discovery_document) @@ -788,7 +807,13 @@ class method: from requests_oauth2client import OAuth2Client, ClientSecretJwt oauth2client = OAuth2Client.from_discovery_endpoint( - "https://url.to.the/.well-known/openid-configuration", + "https://url.to.the.as/.well-known/openid-configuration", + auth=ClientSecretJwt("client_id", "client_secret"), +) + +# OR, if you know the issuer value +oauth2client = OAuth2Client.from_discovery_endpoint( + issuer="https://url.to.the.as", auth=ClientSecretJwt("client_id", "client_secret"), ) ``` @@ -796,6 +821,9 @@ oauth2client = OAuth2Client.from_discovery_endpoint( This will fetch the document from the specified URI, then will decode it and initialize an [OAuth2Client] pointing to the appropriate endpoint URIs. +If using the `issuer` keyword arg, the URI to the discovery endpoint will be deduced from that identifier, and a check will +be made that the `issuer` from the retrieved metadata document matches that value. + ## Specialized API Client Using APIs usually involves multiple endpoints under the same root url, with a common authentication method. To make it @@ -820,9 +848,9 @@ api = ApiClient( resp = api.get("/resource/foo") ``` -Note that [ApiClient] will never send requests "outside" its configured root url, unless you specifically give it a full -url at request time. The leading `/` in `/resource` above is optional. A leading `/` will not "reset" the url path to -root, which means that you can also write the relative path without the `/` and it will automatically be included: +Note that [ApiClient] will never send requests "outside" its configured root url. The leading `/` in `/resource` above +is optional. A leading `/` will not "reset" the url path to root, which means that you can also write the relative path +without the `/` and it will automatically be included: ```python api.get("resource/foo") # will also send a GET to https://myapi.local/root/resource/foo @@ -895,23 +923,28 @@ assert api.session == session ## Vendor-Specific clients -`requests_oauth2client` being flexible enough to handle most use cases, you should be able to use any AS by any vendor +`requests_oauth2client` is flexible enough to handle most use cases, so you should be able to use any AS by any vendor as long as it supports OAuth 2.0. You can however create a subclass of [OAuth2Client] or [ApiClient] to make it easier to use with specific Authorization -Servers or APIs. The sub-module `requests_oauth2client.vendor_specific` includes such classes for -[Auth0](https://auth0.com): +Servers or APIs. [OAuth2Client] has several extensibility points in the form of methods like `OAuth2Client.parse_token_response()`, +`OAuth2Client.on_token_error()` that implement response parsing, error handling, etc. + ```python -from requests_oauth2client.vendor_specific import Auth0Client, Auth0ManagementApiClient +from requests_oauth2client.vendor_specific import Auth0 -a0client = Auth0Client("mytenant.eu", ("client_id", "client_secret")) +a0client = Auth0.client( + "mytenant.eu", client_id="client_id", client_secret="client_secret" +) # this will automatically initialize the token endpoint to https://mytenant.eu.auth0.com/oauth/token # and other endpoints accordingly token = a0client.client_credentials(audience="audience") # this is a wrapper around Auth0 Management API -a0mgmt = Auth0ManagementApiClient("mytenant.eu", ("client_id", "client_secret")) +a0mgmt = Auth0.management_api_client( + "mytenant.eu", client_id="client_id", client_secret="client_secret" +) myusers = a0mgmt.get("users") ``` diff --git a/mkdocs.yml b/mkdocs.yml index df9fe89..03facf2 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -2,7 +2,10 @@ site_name: requests_oauth2client # site_url: http://www.jieyu.ai repo_url: https://github.com/guillp/requests_oauth2client repo_name: requests_oauth2client -strict: false +strict: true +watch: + - requests_oauth2client + - README.md nav: - Home: index.md - Installation: installation.md @@ -21,9 +24,12 @@ theme: - navigation.tabs - navigation.instant - navigation.tabs.sticky + - navigation.footer + - content.code.copy + - content.action.view markdown_extensions: - pymdownx.emoji: - emoji_index: !!python/name:materialx.emoji.twemoji + emoji_index: !!python/name:material.extensions.emoji.twemoji emoji_generator: !!python/name:materialx.emoji.to_svg - pymdownx.critic - pymdownx.caret @@ -42,16 +48,14 @@ markdown_extensions: - toc: baselevel: 2 permalink: true - slugify: !!python/name:pymdownx.slugs.uslugify + slugify: !!python/object/apply:pymdownx.slugs.slugify {kwds: {case: lower}} - meta plugins: + - include-markdown - search: lang: en - mkdocstrings: default_handler: python - watch: - - requests_oauth2client - - README.md handlers: python: options: diff --git a/poetry.lock b/poetry.lock index d6acd43..912af04 100644 --- a/poetry.lock +++ b/poetry.lock @@ -1,106 +1,75 @@ -# This file is automatically @generated by Poetry 1.6.1 and should not be changed by hand. +# This file is automatically @generated by Poetry 1.7.1 and should not be changed by hand. [[package]] name = "attrs" -version = "23.1.0" +version = "23.2.0" description = "Classes Without Boilerplate" optional = false python-versions = ">=3.7" files = [ - {file = "attrs-23.1.0-py3-none-any.whl", hash = "sha256:1f28b4522cdc2fb4256ac1a020c78acf9cba2c6b461ccd2c126f3aa8e8335d04"}, - {file = "attrs-23.1.0.tar.gz", hash = "sha256:6279836d581513a26f1bf235f9acd333bc9115683f14f7e8fae46c98fc50e015"}, + {file = "attrs-23.2.0-py3-none-any.whl", hash = "sha256:99b87a485a5820b23b879f04c2305b44b951b502fd64be915879d77a7e8fc6f1"}, + {file = "attrs-23.2.0.tar.gz", hash = "sha256:935dc3b529c262f6cf76e50877d35a4bd3c1de194fd41f47a2b7ae8f19971f30"}, ] [package.extras] cov = ["attrs[tests]", "coverage[toml] (>=5.3)"] -dev = ["attrs[docs,tests]", "pre-commit"] +dev = ["attrs[tests]", "pre-commit"] docs = ["furo", "myst-parser", "sphinx", "sphinx-notfound-page", "sphinxcontrib-towncrier", "towncrier", "zope-interface"] tests = ["attrs[tests-no-zope]", "zope-interface"] -tests-no-zope = ["cloudpickle", "hypothesis", "mypy (>=1.1.1)", "pympler", "pytest (>=4.3.0)", "pytest-mypy-plugins", "pytest-xdist[psutil]"] +tests-mypy = ["mypy (>=1.6)", "pytest-mypy-plugins"] +tests-no-zope = ["attrs[tests-mypy]", "cloudpickle", "hypothesis", "pympler", "pytest (>=4.3.0)", "pytest-xdist[psutil]"] [[package]] name = "babel" -version = "2.13.1" +version = "2.14.0" description = "Internationalization utilities" optional = false python-versions = ">=3.7" files = [ - {file = "Babel-2.13.1-py3-none-any.whl", hash = "sha256:7077a4984b02b6727ac10f1f7294484f737443d7e2e66c5e4380e41a3ae0b4ed"}, - {file = "Babel-2.13.1.tar.gz", hash = "sha256:33e0952d7dd6374af8dbf6768cc4ddf3ccfefc244f9986d4074704f2fbd18900"}, + {file = "Babel-2.14.0-py3-none-any.whl", hash = "sha256:efb1a25b7118e67ce3a259bed20545c29cb68be8ad2c784c83689981b7a57287"}, + {file = "Babel-2.14.0.tar.gz", hash = "sha256:6919867db036398ba21eb5c7a0f6b28ab8cbc3ae7a73a44ebe34ae74a4e7d363"}, ] [package.dependencies] pytz = {version = ">=2015.7", markers = "python_version < \"3.9\""} -setuptools = {version = "*", markers = "python_version >= \"3.12\""} [package.extras] dev = ["freezegun (>=1.0,<2.0)", "pytest (>=6.0)", "pytest-cov"] [[package]] name = "binapy" -version = "0.7.0" +version = "0.8.0" description = "Binary Data manipulation, for humans." optional = false python-versions = ">=3.8" files = [ - {file = "binapy-0.7.0-py3-none-any.whl", hash = "sha256:739cd5bebd52715b8c8face6ff815bf5798306cf276b392e959ada85b9a9bee6"}, - {file = "binapy-0.7.0.tar.gz", hash = "sha256:e26f10ec6566a670e07dcc9de4c223be60984a7b1a2e5436b7eb6555f1d9d23b"}, + {file = "binapy-0.8.0-py3-none-any.whl", hash = "sha256:8af1e1e856900ef8b79ef32236e296127c9cf26414ec355982ff7ce5f173504d"}, + {file = "binapy-0.8.0.tar.gz", hash = "sha256:570c5098d42f037ffb3d2e563998f3cff69ad25ca1f43f9c3815432dccd08233"}, ] [package.dependencies] typing-extensions = ">=4.3.0" [[package]] -name = "black" -version = "23.11.0" -description = "The uncompromising code formatter." +name = "blinker" +version = "1.7.0" +description = "Fast, simple object-to-object and broadcast signaling" optional = false python-versions = ">=3.8" files = [ - {file = "black-23.11.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:dbea0bb8575c6b6303cc65017b46351dc5953eea5c0a59d7b7e3a2d2f433a911"}, - {file = "black-23.11.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:412f56bab20ac85927f3a959230331de5614aecda1ede14b373083f62ec24e6f"}, - {file = "black-23.11.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d136ef5b418c81660ad847efe0e55c58c8208b77a57a28a503a5f345ccf01394"}, - {file = "black-23.11.0-cp310-cp310-win_amd64.whl", hash = "sha256:6c1cac07e64433f646a9a838cdc00c9768b3c362805afc3fce341af0e6a9ae9f"}, - {file = "black-23.11.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:cf57719e581cfd48c4efe28543fea3d139c6b6f1238b3f0102a9c73992cbb479"}, - {file = "black-23.11.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:698c1e0d5c43354ec5d6f4d914d0d553a9ada56c85415700b81dc90125aac244"}, - {file = "black-23.11.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:760415ccc20f9e8747084169110ef75d545f3b0932ee21368f63ac0fee86b221"}, - {file = "black-23.11.0-cp311-cp311-win_amd64.whl", hash = "sha256:58e5f4d08a205b11800332920e285bd25e1a75c54953e05502052738fe16b3b5"}, - {file = "black-23.11.0-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:45aa1d4675964946e53ab81aeec7a37613c1cb71647b5394779e6efb79d6d187"}, - {file = "black-23.11.0-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:4c44b7211a3a0570cc097e81135faa5f261264f4dfaa22bd5ee2875a4e773bd6"}, - {file = "black-23.11.0-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2a9acad1451632021ee0d146c8765782a0c3846e0e0ea46659d7c4f89d9b212b"}, - {file = "black-23.11.0-cp38-cp38-win_amd64.whl", hash = "sha256:fc7f6a44d52747e65a02558e1d807c82df1d66ffa80a601862040a43ec2e3142"}, - {file = "black-23.11.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:7f622b6822f02bfaf2a5cd31fdb7cd86fcf33dab6ced5185c35f5db98260b055"}, - {file = "black-23.11.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:250d7e60f323fcfc8ea6c800d5eba12f7967400eb6c2d21ae85ad31c204fb1f4"}, - {file = "black-23.11.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5133f5507007ba08d8b7b263c7aa0f931af5ba88a29beacc4b2dc23fcefe9c06"}, - {file = "black-23.11.0-cp39-cp39-win_amd64.whl", hash = "sha256:421f3e44aa67138ab1b9bfbc22ee3780b22fa5b291e4db8ab7eee95200726b07"}, - {file = "black-23.11.0-py3-none-any.whl", hash = "sha256:54caaa703227c6e0c87b76326d0862184729a69b73d3b7305b6288e1d830067e"}, - {file = "black-23.11.0.tar.gz", hash = "sha256:4c68855825ff432d197229846f971bc4d6666ce90492e5b02013bcaca4d9ab05"}, + {file = "blinker-1.7.0-py3-none-any.whl", hash = "sha256:c3f865d4d54db7abc53758a01601cf343fe55b84c1de4e3fa910e420b438d5b9"}, + {file = "blinker-1.7.0.tar.gz", hash = "sha256:e6820ff6fa4e4d1d8e2747c2283749c3f547e4fee112b98555cdcdae32996182"}, ] -[package.dependencies] -click = ">=8.0.0" -mypy-extensions = ">=0.4.3" -packaging = ">=22.0" -pathspec = ">=0.9.0" -platformdirs = ">=2" -tomli = {version = ">=1.1.0", markers = "python_version < \"3.11\""} -typing-extensions = {version = ">=4.0.1", markers = "python_version < \"3.11\""} - -[package.extras] -colorama = ["colorama (>=0.4.3)"] -d = ["aiohttp (>=3.7.4)"] -jupyter = ["ipython (>=7.8.0)", "tokenize-rt (>=3.2.0)"] -uvloop = ["uvloop (>=0.15.2)"] - [[package]] -name = "blinker" -version = "1.7.0" -description = "Fast, simple object-to-object and broadcast signaling" +name = "bracex" +version = "2.4" +description = "Bash style brace expander." optional = false python-versions = ">=3.8" files = [ - {file = "blinker-1.7.0-py3-none-any.whl", hash = "sha256:c3f865d4d54db7abc53758a01601cf343fe55b84c1de4e3fa910e420b438d5b9"}, - {file = "blinker-1.7.0.tar.gz", hash = "sha256:e6820ff6fa4e4d1d8e2747c2283749c3f547e4fee112b98555cdcdae32996182"}, + {file = "bracex-2.4-py3-none-any.whl", hash = "sha256:efdc71eff95eaff5e0f8cfebe7d01adf2c8637c8c92edaf63ef348c241a82418"}, + {file = "bracex-2.4.tar.gz", hash = "sha256:a27eaf1df42cf561fed58b7a8f3fdf129d1ea16a81e1fadd1d17989bc6384beb"}, ] [[package]] @@ -116,13 +85,13 @@ files = [ [[package]] name = "certifi" -version = "2023.11.17" +version = "2024.2.2" description = "Python package for providing Mozilla's CA Bundle." optional = false python-versions = ">=3.6" files = [ - {file = "certifi-2023.11.17-py3-none-any.whl", hash = "sha256:e036ab49d5b79556f99cfc2d9320b34cfbe5be05c5871b51de9329f0603b0474"}, - {file = "certifi-2023.11.17.tar.gz", hash = "sha256:9b469f3a900bf28dc19b8cfbf8019bf47f7fdd1a65a1d4ffb98fc14166beb4d1"}, + {file = "certifi-2024.2.2-py3-none-any.whl", hash = "sha256:dc383c07b76109f368f6106eee2b593b04a011ea4d55f652c6ca24a754d1cdd1"}, + {file = "certifi-2024.2.2.tar.gz", hash = "sha256:0569859f95fc761b18b45ef421b1290a0f65f147e92a1e5eb3e635f9a5e4e66f"}, ] [[package]] @@ -337,63 +306,63 @@ files = [ [[package]] name = "coverage" -version = "7.3.2" +version = "7.4.1" description = "Code coverage measurement for Python" optional = false python-versions = ">=3.8" files = [ - {file = "coverage-7.3.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:d872145f3a3231a5f20fd48500274d7df222e291d90baa2026cc5152b7ce86bf"}, - {file = "coverage-7.3.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:310b3bb9c91ea66d59c53fa4989f57d2436e08f18fb2f421a1b0b6b8cc7fffda"}, - {file = "coverage-7.3.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f47d39359e2c3779c5331fc740cf4bce6d9d680a7b4b4ead97056a0ae07cb49a"}, - {file = "coverage-7.3.2-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:aa72dbaf2c2068404b9870d93436e6d23addd8bbe9295f49cbca83f6e278179c"}, - {file = "coverage-7.3.2-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:beaa5c1b4777f03fc63dfd2a6bd820f73f036bfb10e925fce067b00a340d0f3f"}, - {file = "coverage-7.3.2-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:dbc1b46b92186cc8074fee9d9fbb97a9dd06c6cbbef391c2f59d80eabdf0faa6"}, - {file = "coverage-7.3.2-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:315a989e861031334d7bee1f9113c8770472db2ac484e5b8c3173428360a9148"}, - {file = "coverage-7.3.2-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:d1bc430677773397f64a5c88cb522ea43175ff16f8bfcc89d467d974cb2274f9"}, - {file = "coverage-7.3.2-cp310-cp310-win32.whl", hash = "sha256:a889ae02f43aa45032afe364c8ae84ad3c54828c2faa44f3bfcafecb5c96b02f"}, - {file = "coverage-7.3.2-cp310-cp310-win_amd64.whl", hash = "sha256:c0ba320de3fb8c6ec16e0be17ee1d3d69adcda99406c43c0409cb5c41788a611"}, - {file = "coverage-7.3.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:ac8c802fa29843a72d32ec56d0ca792ad15a302b28ca6203389afe21f8fa062c"}, - {file = "coverage-7.3.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:89a937174104339e3a3ffcf9f446c00e3a806c28b1841c63edb2b369310fd074"}, - {file = "coverage-7.3.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e267e9e2b574a176ddb983399dec325a80dbe161f1a32715c780b5d14b5f583a"}, - {file = "coverage-7.3.2-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:2443cbda35df0d35dcfb9bf8f3c02c57c1d6111169e3c85fc1fcc05e0c9f39a3"}, - {file = "coverage-7.3.2-cp311-cp311-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4175e10cc8dda0265653e8714b3174430b07c1dca8957f4966cbd6c2b1b8065a"}, - {file = "coverage-7.3.2-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:0cbf38419fb1a347aaf63481c00f0bdc86889d9fbf3f25109cf96c26b403fda1"}, - {file = "coverage-7.3.2-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:5c913b556a116b8d5f6ef834038ba983834d887d82187c8f73dec21049abd65c"}, - {file = "coverage-7.3.2-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:1981f785239e4e39e6444c63a98da3a1db8e971cb9ceb50a945ba6296b43f312"}, - {file = "coverage-7.3.2-cp311-cp311-win32.whl", hash = "sha256:43668cabd5ca8258f5954f27a3aaf78757e6acf13c17604d89648ecc0cc66640"}, - {file = "coverage-7.3.2-cp311-cp311-win_amd64.whl", hash = "sha256:e10c39c0452bf6e694511c901426d6b5ac005acc0f78ff265dbe36bf81f808a2"}, - {file = "coverage-7.3.2-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:4cbae1051ab791debecc4a5dcc4a1ff45fc27b91b9aee165c8a27514dd160836"}, - {file = "coverage-7.3.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:12d15ab5833a997716d76f2ac1e4b4d536814fc213c85ca72756c19e5a6b3d63"}, - {file = "coverage-7.3.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3c7bba973ebee5e56fe9251300c00f1579652587a9f4a5ed8404b15a0471f216"}, - {file = "coverage-7.3.2-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:fe494faa90ce6381770746077243231e0b83ff3f17069d748f645617cefe19d4"}, - {file = "coverage-7.3.2-cp312-cp312-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f6e9589bd04d0461a417562649522575d8752904d35c12907d8c9dfeba588faf"}, - {file = "coverage-7.3.2-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:d51ac2a26f71da1b57f2dc81d0e108b6ab177e7d30e774db90675467c847bbdf"}, - {file = "coverage-7.3.2-cp312-cp312-musllinux_1_1_i686.whl", hash = "sha256:99b89d9f76070237975b315b3d5f4d6956ae354a4c92ac2388a5695516e47c84"}, - {file = "coverage-7.3.2-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:fa28e909776dc69efb6ed975a63691bc8172b64ff357e663a1bb06ff3c9b589a"}, - {file = "coverage-7.3.2-cp312-cp312-win32.whl", hash = "sha256:289fe43bf45a575e3ab10b26d7b6f2ddb9ee2dba447499f5401cfb5ecb8196bb"}, - {file = "coverage-7.3.2-cp312-cp312-win_amd64.whl", hash = "sha256:7dbc3ed60e8659bc59b6b304b43ff9c3ed858da2839c78b804973f613d3e92ed"}, - {file = "coverage-7.3.2-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:f94b734214ea6a36fe16e96a70d941af80ff3bfd716c141300d95ebc85339738"}, - {file = "coverage-7.3.2-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:af3d828d2c1cbae52d34bdbb22fcd94d1ce715d95f1a012354a75e5913f1bda2"}, - {file = "coverage-7.3.2-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:630b13e3036e13c7adc480ca42fa7afc2a5d938081d28e20903cf7fd687872e2"}, - {file = "coverage-7.3.2-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c9eacf273e885b02a0273bb3a2170f30e2d53a6d53b72dbe02d6701b5296101c"}, - {file = "coverage-7.3.2-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d8f17966e861ff97305e0801134e69db33b143bbfb36436efb9cfff6ec7b2fd9"}, - {file = "coverage-7.3.2-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:b4275802d16882cf9c8b3d057a0839acb07ee9379fa2749eca54efbce1535b82"}, - {file = "coverage-7.3.2-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:72c0cfa5250f483181e677ebc97133ea1ab3eb68645e494775deb6a7f6f83901"}, - {file = "coverage-7.3.2-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:cb536f0dcd14149425996821a168f6e269d7dcd2c273a8bff8201e79f5104e76"}, - {file = "coverage-7.3.2-cp38-cp38-win32.whl", hash = "sha256:307adb8bd3abe389a471e649038a71b4eb13bfd6b7dd9a129fa856f5c695cf92"}, - {file = "coverage-7.3.2-cp38-cp38-win_amd64.whl", hash = "sha256:88ed2c30a49ea81ea3b7f172e0269c182a44c236eb394718f976239892c0a27a"}, - {file = "coverage-7.3.2-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:b631c92dfe601adf8f5ebc7fc13ced6bb6e9609b19d9a8cd59fa47c4186ad1ce"}, - {file = "coverage-7.3.2-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:d3d9df4051c4a7d13036524b66ecf7a7537d14c18a384043f30a303b146164e9"}, - {file = "coverage-7.3.2-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5f7363d3b6a1119ef05015959ca24a9afc0ea8a02c687fe7e2d557705375c01f"}, - {file = "coverage-7.3.2-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:2f11cc3c967a09d3695d2a6f03fb3e6236622b93be7a4b5dc09166a861be6d25"}, - {file = "coverage-7.3.2-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:149de1d2401ae4655c436a3dced6dd153f4c3309f599c3d4bd97ab172eaf02d9"}, - {file = "coverage-7.3.2-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:3a4006916aa6fee7cd38db3bfc95aa9c54ebb4ffbfc47c677c8bba949ceba0a6"}, - {file = "coverage-7.3.2-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:9028a3871280110d6e1aa2df1afd5ef003bab5fb1ef421d6dc748ae1c8ef2ebc"}, - {file = "coverage-7.3.2-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:9f805d62aec8eb92bab5b61c0f07329275b6f41c97d80e847b03eb894f38d083"}, - {file = "coverage-7.3.2-cp39-cp39-win32.whl", hash = "sha256:d1c88ec1a7ff4ebca0219f5b1ef863451d828cccf889c173e1253aa84b1e07ce"}, - {file = "coverage-7.3.2-cp39-cp39-win_amd64.whl", hash = "sha256:b4767da59464bb593c07afceaddea61b154136300881844768037fd5e859353f"}, - {file = "coverage-7.3.2-pp38.pp39.pp310-none-any.whl", hash = "sha256:ae97af89f0fbf373400970c0a21eef5aa941ffeed90aee43650b81f7d7f47637"}, - {file = "coverage-7.3.2.tar.gz", hash = "sha256:be32ad29341b0170e795ca590e1c07e81fc061cb5b10c74ce7203491484404ef"}, + {file = "coverage-7.4.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:077d366e724f24fc02dbfe9d946534357fda71af9764ff99d73c3c596001bbd7"}, + {file = "coverage-7.4.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:0193657651f5399d433c92f8ae264aff31fc1d066deee4b831549526433f3f61"}, + {file = "coverage-7.4.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d17bbc946f52ca67adf72a5ee783cd7cd3477f8f8796f59b4974a9b59cacc9ee"}, + {file = "coverage-7.4.1-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:a3277f5fa7483c927fe3a7b017b39351610265308f5267ac6d4c2b64cc1d8d25"}, + {file = "coverage-7.4.1-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6dceb61d40cbfcf45f51e59933c784a50846dc03211054bd76b421a713dcdf19"}, + {file = "coverage-7.4.1-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:6008adeca04a445ea6ef31b2cbaf1d01d02986047606f7da266629afee982630"}, + {file = "coverage-7.4.1-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:c61f66d93d712f6e03369b6a7769233bfda880b12f417eefdd4f16d1deb2fc4c"}, + {file = "coverage-7.4.1-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:b9bb62fac84d5f2ff523304e59e5c439955fb3b7f44e3d7b2085184db74d733b"}, + {file = "coverage-7.4.1-cp310-cp310-win32.whl", hash = "sha256:f86f368e1c7ce897bf2457b9eb61169a44e2ef797099fb5728482b8d69f3f016"}, + {file = "coverage-7.4.1-cp310-cp310-win_amd64.whl", hash = "sha256:869b5046d41abfea3e381dd143407b0d29b8282a904a19cb908fa24d090cc018"}, + {file = "coverage-7.4.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:b8ffb498a83d7e0305968289441914154fb0ef5d8b3157df02a90c6695978295"}, + {file = "coverage-7.4.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:3cacfaefe6089d477264001f90f55b7881ba615953414999c46cc9713ff93c8c"}, + {file = "coverage-7.4.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5d6850e6e36e332d5511a48a251790ddc545e16e8beaf046c03985c69ccb2676"}, + {file = "coverage-7.4.1-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:18e961aa13b6d47f758cc5879383d27b5b3f3dcd9ce8cdbfdc2571fe86feb4dd"}, + {file = "coverage-7.4.1-cp311-cp311-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:dfd1e1b9f0898817babf840b77ce9fe655ecbe8b1b327983df485b30df8cc011"}, + {file = "coverage-7.4.1-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:6b00e21f86598b6330f0019b40fb397e705135040dbedc2ca9a93c7441178e74"}, + {file = "coverage-7.4.1-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:536d609c6963c50055bab766d9951b6c394759190d03311f3e9fcf194ca909e1"}, + {file = "coverage-7.4.1-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:7ac8f8eb153724f84885a1374999b7e45734bf93a87d8df1e7ce2146860edef6"}, + {file = "coverage-7.4.1-cp311-cp311-win32.whl", hash = "sha256:f3771b23bb3675a06f5d885c3630b1d01ea6cac9e84a01aaf5508706dba546c5"}, + {file = "coverage-7.4.1-cp311-cp311-win_amd64.whl", hash = "sha256:9d2f9d4cc2a53b38cabc2d6d80f7f9b7e3da26b2f53d48f05876fef7956b6968"}, + {file = "coverage-7.4.1-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:f68ef3660677e6624c8cace943e4765545f8191313a07288a53d3da188bd8581"}, + {file = "coverage-7.4.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:23b27b8a698e749b61809fb637eb98ebf0e505710ec46a8aa6f1be7dc0dc43a6"}, + {file = "coverage-7.4.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3e3424c554391dc9ef4a92ad28665756566a28fecf47308f91841f6c49288e66"}, + {file = "coverage-7.4.1-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e0860a348bf7004c812c8368d1fc7f77fe8e4c095d661a579196a9533778e156"}, + {file = "coverage-7.4.1-cp312-cp312-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fe558371c1bdf3b8fa03e097c523fb9645b8730399c14fe7721ee9c9e2a545d3"}, + {file = "coverage-7.4.1-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:3468cc8720402af37b6c6e7e2a9cdb9f6c16c728638a2ebc768ba1ef6f26c3a1"}, + {file = "coverage-7.4.1-cp312-cp312-musllinux_1_1_i686.whl", hash = "sha256:02f2edb575d62172aa28fe00efe821ae31f25dc3d589055b3fb64d51e52e4ab1"}, + {file = "coverage-7.4.1-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:ca6e61dc52f601d1d224526360cdeab0d0712ec104a2ce6cc5ccef6ed9a233bc"}, + {file = "coverage-7.4.1-cp312-cp312-win32.whl", hash = "sha256:ca7b26a5e456a843b9b6683eada193fc1f65c761b3a473941efe5a291f604c74"}, + {file = "coverage-7.4.1-cp312-cp312-win_amd64.whl", hash = "sha256:85ccc5fa54c2ed64bd91ed3b4a627b9cce04646a659512a051fa82a92c04a448"}, + {file = "coverage-7.4.1-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:8bdb0285a0202888d19ec6b6d23d5990410decb932b709f2b0dfe216d031d218"}, + {file = "coverage-7.4.1-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:918440dea04521f499721c039863ef95433314b1db00ff826a02580c1f503e45"}, + {file = "coverage-7.4.1-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:379d4c7abad5afbe9d88cc31ea8ca262296480a86af945b08214eb1a556a3e4d"}, + {file = "coverage-7.4.1-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b094116f0b6155e36a304ff912f89bbb5067157aff5f94060ff20bbabdc8da06"}, + {file = "coverage-7.4.1-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f2f5968608b1fe2a1d00d01ad1017ee27efd99b3437e08b83ded9b7af3f6f766"}, + {file = "coverage-7.4.1-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:10e88e7f41e6197ea0429ae18f21ff521d4f4490aa33048f6c6f94c6045a6a75"}, + {file = "coverage-7.4.1-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:a4a3907011d39dbc3e37bdc5df0a8c93853c369039b59efa33a7b6669de04c60"}, + {file = "coverage-7.4.1-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:6d224f0c4c9c98290a6990259073f496fcec1b5cc613eecbd22786d398ded3ad"}, + {file = "coverage-7.4.1-cp38-cp38-win32.whl", hash = "sha256:23f5881362dcb0e1a92b84b3c2809bdc90db892332daab81ad8f642d8ed55042"}, + {file = "coverage-7.4.1-cp38-cp38-win_amd64.whl", hash = "sha256:a07f61fc452c43cd5328b392e52555f7d1952400a1ad09086c4a8addccbd138d"}, + {file = "coverage-7.4.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:8e738a492b6221f8dcf281b67129510835461132b03024830ac0e554311a5c54"}, + {file = "coverage-7.4.1-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:46342fed0fff72efcda77040b14728049200cbba1279e0bf1188f1f2078c1d70"}, + {file = "coverage-7.4.1-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9641e21670c68c7e57d2053ddf6c443e4f0a6e18e547e86af3fad0795414a628"}, + {file = "coverage-7.4.1-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:aeb2c2688ed93b027eb0d26aa188ada34acb22dceea256d76390eea135083950"}, + {file = "coverage-7.4.1-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d12c923757de24e4e2110cf8832d83a886a4cf215c6e61ed506006872b43a6d1"}, + {file = "coverage-7.4.1-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:0491275c3b9971cdbd28a4595c2cb5838f08036bca31765bad5e17edf900b2c7"}, + {file = "coverage-7.4.1-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:8dfc5e195bbef80aabd81596ef52a1277ee7143fe419efc3c4d8ba2754671756"}, + {file = "coverage-7.4.1-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:1a78b656a4d12b0490ca72651fe4d9f5e07e3c6461063a9b6265ee45eb2bdd35"}, + {file = "coverage-7.4.1-cp39-cp39-win32.whl", hash = "sha256:f90515974b39f4dea2f27c0959688621b46d96d5a626cf9c53dbc653a895c05c"}, + {file = "coverage-7.4.1-cp39-cp39-win_amd64.whl", hash = "sha256:64e723ca82a84053dd7bfcc986bdb34af8d9da83c521c19d6b472bc6880e191a"}, + {file = "coverage-7.4.1-pp38.pp39.pp310-none-any.whl", hash = "sha256:32a8d985462e37cfdab611a6f95b09d7c091d07668fdc26e47a725ee575fe166"}, + {file = "coverage-7.4.1.tar.gz", hash = "sha256:1ed4b95480952b1a26d863e546fa5094564aa0065e1e5f0d4d0041f293251d04"}, ] [package.dependencies] @@ -404,58 +373,67 @@ toml = ["tomli"] [[package]] name = "cryptography" -version = "41.0.7" +version = "42.0.2" description = "cryptography is a package which provides cryptographic recipes and primitives to Python developers." optional = false python-versions = ">=3.7" files = [ - {file = "cryptography-41.0.7-cp37-abi3-macosx_10_12_universal2.whl", hash = "sha256:3c78451b78313fa81607fa1b3f1ae0a5ddd8014c38a02d9db0616133987b9cdf"}, - {file = "cryptography-41.0.7-cp37-abi3-macosx_10_12_x86_64.whl", hash = "sha256:928258ba5d6f8ae644e764d0f996d61a8777559f72dfeb2eea7e2fe0ad6e782d"}, - {file = "cryptography-41.0.7-cp37-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5a1b41bc97f1ad230a41657d9155113c7521953869ae57ac39ac7f1bb471469a"}, - {file = "cryptography-41.0.7-cp37-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:841df4caa01008bad253bce2a6f7b47f86dc9f08df4b433c404def869f590a15"}, - {file = "cryptography-41.0.7-cp37-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:5429ec739a29df2e29e15d082f1d9ad683701f0ec7709ca479b3ff2708dae65a"}, - {file = "cryptography-41.0.7-cp37-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:43f2552a2378b44869fe8827aa19e69512e3245a219104438692385b0ee119d1"}, - {file = "cryptography-41.0.7-cp37-abi3-musllinux_1_1_aarch64.whl", hash = "sha256:af03b32695b24d85a75d40e1ba39ffe7db7ffcb099fe507b39fd41a565f1b157"}, - {file = "cryptography-41.0.7-cp37-abi3-musllinux_1_1_x86_64.whl", hash = "sha256:49f0805fc0b2ac8d4882dd52f4a3b935b210935d500b6b805f321addc8177406"}, - {file = "cryptography-41.0.7-cp37-abi3-win32.whl", hash = "sha256:f983596065a18a2183e7f79ab3fd4c475205b839e02cbc0efbbf9666c4b3083d"}, - {file = "cryptography-41.0.7-cp37-abi3-win_amd64.whl", hash = "sha256:90452ba79b8788fa380dfb587cca692976ef4e757b194b093d845e8d99f612f2"}, - {file = "cryptography-41.0.7-pp310-pypy310_pp73-macosx_10_12_x86_64.whl", hash = "sha256:079b85658ea2f59c4f43b70f8119a52414cdb7be34da5d019a77bf96d473b960"}, - {file = "cryptography-41.0.7-pp310-pypy310_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:b640981bf64a3e978a56167594a0e97db71c89a479da8e175d8bb5be5178c003"}, - {file = "cryptography-41.0.7-pp310-pypy310_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:e3114da6d7f95d2dee7d3f4eec16dacff819740bbab931aff8648cb13c5ff5e7"}, - {file = "cryptography-41.0.7-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:d5ec85080cce7b0513cfd233914eb8b7bbd0633f1d1703aa28d1dd5a72f678ec"}, - {file = "cryptography-41.0.7-pp38-pypy38_pp73-macosx_10_12_x86_64.whl", hash = "sha256:7a698cb1dac82c35fcf8fe3417a3aaba97de16a01ac914b89a0889d364d2f6be"}, - {file = "cryptography-41.0.7-pp38-pypy38_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:37a138589b12069efb424220bf78eac59ca68b95696fc622b6ccc1c0a197204a"}, - {file = "cryptography-41.0.7-pp38-pypy38_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:68a2dec79deebc5d26d617bfdf6e8aab065a4f34934b22d3b5010df3ba36612c"}, - {file = "cryptography-41.0.7-pp38-pypy38_pp73-win_amd64.whl", hash = "sha256:09616eeaef406f99046553b8a40fbf8b1e70795a91885ba4c96a70793de5504a"}, - {file = "cryptography-41.0.7-pp39-pypy39_pp73-macosx_10_12_x86_64.whl", hash = "sha256:48a0476626da912a44cc078f9893f292f0b3e4c739caf289268168d8f4702a39"}, - {file = "cryptography-41.0.7-pp39-pypy39_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:c7f3201ec47d5207841402594f1d7950879ef890c0c495052fa62f58283fde1a"}, - {file = "cryptography-41.0.7-pp39-pypy39_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:c5ca78485a255e03c32b513f8c2bc39fedb7f5c5f8535545bdc223a03b24f248"}, - {file = "cryptography-41.0.7-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:d6c391c021ab1f7a82da5d8d0b3cee2f4b2c455ec86c8aebbc84837a631ff309"}, - {file = "cryptography-41.0.7.tar.gz", hash = "sha256:13f93ce9bea8016c253b34afc6bd6a75993e5c40672ed5405a9c832f0d4a00bc"}, + {file = "cryptography-42.0.2-cp37-abi3-macosx_10_12_universal2.whl", hash = "sha256:701171f825dcab90969596ce2af253143b93b08f1a716d4b2a9d2db5084ef7be"}, + {file = "cryptography-42.0.2-cp37-abi3-macosx_10_12_x86_64.whl", hash = "sha256:61321672b3ac7aade25c40449ccedbc6db72c7f5f0fdf34def5e2f8b51ca530d"}, + {file = "cryptography-42.0.2-cp37-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ea2c3ffb662fec8bbbfce5602e2c159ff097a4631d96235fcf0fb00e59e3ece4"}, + {file = "cryptography-42.0.2-cp37-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3b15c678f27d66d247132cbf13df2f75255627bcc9b6a570f7d2fd08e8c081d2"}, + {file = "cryptography-42.0.2-cp37-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:8e88bb9eafbf6a4014d55fb222e7360eef53e613215085e65a13290577394529"}, + {file = "cryptography-42.0.2-cp37-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:a047682d324ba56e61b7ea7c7299d51e61fd3bca7dad2ccc39b72bd0118d60a1"}, + {file = "cryptography-42.0.2-cp37-abi3-musllinux_1_1_aarch64.whl", hash = "sha256:36d4b7c4be6411f58f60d9ce555a73df8406d484ba12a63549c88bd64f7967f1"}, + {file = "cryptography-42.0.2-cp37-abi3-musllinux_1_1_x86_64.whl", hash = "sha256:a00aee5d1b6c20620161984f8ab2ab69134466c51f58c052c11b076715e72929"}, + {file = "cryptography-42.0.2-cp37-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:b97fe7d7991c25e6a31e5d5e795986b18fbbb3107b873d5f3ae6dc9a103278e9"}, + {file = "cryptography-42.0.2-cp37-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:5fa82a26f92871eca593b53359c12ad7949772462f887c35edaf36f87953c0e2"}, + {file = "cryptography-42.0.2-cp37-abi3-win32.whl", hash = "sha256:4b063d3413f853e056161eb0c7724822a9740ad3caa24b8424d776cebf98e7ee"}, + {file = "cryptography-42.0.2-cp37-abi3-win_amd64.whl", hash = "sha256:841ec8af7a8491ac76ec5a9522226e287187a3107e12b7d686ad354bb78facee"}, + {file = "cryptography-42.0.2-cp39-abi3-macosx_10_12_universal2.whl", hash = "sha256:55d1580e2d7e17f45d19d3b12098e352f3a37fe86d380bf45846ef257054b242"}, + {file = "cryptography-42.0.2-cp39-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:28cb2c41f131a5758d6ba6a0504150d644054fd9f3203a1e8e8d7ac3aea7f73a"}, + {file = "cryptography-42.0.2-cp39-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b9097a208875fc7bbeb1286d0125d90bdfed961f61f214d3f5be62cd4ed8a446"}, + {file = "cryptography-42.0.2-cp39-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:44c95c0e96b3cb628e8452ec060413a49002a247b2b9938989e23a2c8291fc90"}, + {file = "cryptography-42.0.2-cp39-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:2f9f14185962e6a04ab32d1abe34eae8a9001569ee4edb64d2304bf0d65c53f3"}, + {file = "cryptography-42.0.2-cp39-abi3-musllinux_1_1_aarch64.whl", hash = "sha256:09a77e5b2e8ca732a19a90c5bca2d124621a1edb5438c5daa2d2738bfeb02589"}, + {file = "cryptography-42.0.2-cp39-abi3-musllinux_1_1_x86_64.whl", hash = "sha256:ad28cff53f60d99a928dfcf1e861e0b2ceb2bc1f08a074fdd601b314e1cc9e0a"}, + {file = "cryptography-42.0.2-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:130c0f77022b2b9c99d8cebcdd834d81705f61c68e91ddd614ce74c657f8b3ea"}, + {file = "cryptography-42.0.2-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:fa3dec4ba8fb6e662770b74f62f1a0c7d4e37e25b58b2bf2c1be4c95372b4a33"}, + {file = "cryptography-42.0.2-cp39-abi3-win32.whl", hash = "sha256:3dbd37e14ce795b4af61b89b037d4bc157f2cb23e676fa16932185a04dfbf635"}, + {file = "cryptography-42.0.2-cp39-abi3-win_amd64.whl", hash = "sha256:8a06641fb07d4e8f6c7dda4fc3f8871d327803ab6542e33831c7ccfdcb4d0ad6"}, + {file = "cryptography-42.0.2-pp310-pypy310_pp73-macosx_10_12_x86_64.whl", hash = "sha256:087887e55e0b9c8724cf05361357875adb5c20dec27e5816b653492980d20380"}, + {file = "cryptography-42.0.2-pp310-pypy310_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:a7ef8dd0bf2e1d0a27042b231a3baac6883cdd5557036f5e8df7139255feaac6"}, + {file = "cryptography-42.0.2-pp310-pypy310_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:4383b47f45b14459cab66048d384614019965ba6c1a1a141f11b5a551cace1b2"}, + {file = "cryptography-42.0.2-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:fbeb725c9dc799a574518109336acccaf1303c30d45c075c665c0793c2f79a7f"}, + {file = "cryptography-42.0.2-pp39-pypy39_pp73-macosx_10_12_x86_64.whl", hash = "sha256:320948ab49883557a256eab46149df79435a22d2fefd6a66fe6946f1b9d9d008"}, + {file = "cryptography-42.0.2-pp39-pypy39_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:5ef9bc3d046ce83c4bbf4c25e1e0547b9c441c01d30922d812e887dc5f125c12"}, + {file = "cryptography-42.0.2-pp39-pypy39_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:52ed9ebf8ac602385126c9a2fe951db36f2cb0c2538d22971487f89d0de4065a"}, + {file = "cryptography-42.0.2-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:141e2aa5ba100d3788c0ad7919b288f89d1fe015878b9659b307c9ef867d3a65"}, + {file = "cryptography-42.0.2.tar.gz", hash = "sha256:e0ec52ba3c7f1b7d813cd52649a5b3ef1fc0d433219dc8c93827c57eab6cf888"}, ] [package.dependencies] -cffi = ">=1.12" +cffi = {version = ">=1.12", markers = "platform_python_implementation != \"PyPy\""} [package.extras] docs = ["sphinx (>=5.3.0)", "sphinx-rtd-theme (>=1.1.1)"] -docstest = ["pyenchant (>=1.6.11)", "sphinxcontrib-spelling (>=4.0.1)", "twine (>=1.12.0)"] +docstest = ["pyenchant (>=1.6.11)", "readme-renderer", "sphinxcontrib-spelling (>=4.0.1)"] nox = ["nox"] -pep8test = ["black", "check-sdist", "mypy", "ruff"] +pep8test = ["check-sdist", "click", "mypy", "ruff"] sdist = ["build"] ssh = ["bcrypt (>=3.1.5)"] -test = ["pretend", "pytest (>=6.2.0)", "pytest-benchmark", "pytest-cov", "pytest-xdist"] +test = ["certifi", "pretend", "pytest (>=6.2.0)", "pytest-benchmark", "pytest-cov", "pytest-xdist"] test-randomorder = ["pytest-randomly"] [[package]] name = "distlib" -version = "0.3.7" +version = "0.3.8" description = "Distribution utilities" optional = false python-versions = "*" files = [ - {file = "distlib-0.3.7-py2.py3-none-any.whl", hash = "sha256:2e24928bc811348f0feb63014e97aaae3037f2cf48712d51ae61df7fd6075057"}, - {file = "distlib-0.3.7.tar.gz", hash = "sha256:9dafe54b34a028eafd95039d5e5d4851a13734540f1331060d31c9916e7147a8"}, + {file = "distlib-0.3.8-py2.py3-none-any.whl", hash = "sha256:034db59a0b96f8ca18035f36290806a9a6e6bd9d1ff91e45a7f172eb17e51784"}, + {file = "distlib-0.3.8.tar.gz", hash = "sha256:1530ea13e350031b6312d8580ddb6b27a104275a31106523b8f123787f494f64"}, ] [[package]] @@ -490,13 +468,13 @@ typing = ["typing-extensions (>=4.8)"] [[package]] name = "flask" -version = "3.0.0" +version = "3.0.2" description = "A simple framework for building complex web applications." optional = false python-versions = ">=3.8" files = [ - {file = "flask-3.0.0-py3-none-any.whl", hash = "sha256:21128f47e4e3b9d597a3e8521a329bf56909b690fcc3fa3e477725aa81367638"}, - {file = "flask-3.0.0.tar.gz", hash = "sha256:cfadcdb638b609361d29ec22360d6070a77d7463dcb3ab08d2c2f2f168845f58"}, + {file = "flask-3.0.2-py3-none-any.whl", hash = "sha256:3232e0e9c850d781933cf0207523d1ece087eb8d87b23777ae38456e2fbe7c6e"}, + {file = "flask-3.0.2.tar.gz", hash = "sha256:822c03f4b799204250a7ee84b1eddc40665395333973dfb9deebfe425fefcb7d"}, ] [package.dependencies] @@ -513,13 +491,13 @@ dotenv = ["python-dotenv"] [[package]] name = "freezegun" -version = "1.2.2" +version = "1.4.0" description = "Let your Python tests travel through time" optional = false -python-versions = ">=3.6" +python-versions = ">=3.7" files = [ - {file = "freezegun-1.2.2-py3-none-any.whl", hash = "sha256:ea1b963b993cb9ea195adbd893a48d573fda951b0da64f60883d7e988b606c9f"}, - {file = "freezegun-1.2.2.tar.gz", hash = "sha256:cd22d1ba06941384410cd967d8a99d5ae2442f57dfafeff2fda5de8dc5c05446"}, + {file = "freezegun-1.4.0-py3-none-any.whl", hash = "sha256:55e0fc3c84ebf0a96a5aa23ff8b53d70246479e9a68863f1fcac5a3e52f19dd6"}, + {file = "freezegun-1.4.0.tar.gz", hash = "sha256:10939b0ba0ff5adaecf3b06a5c2f73071d9678e507c5eaedb23c761d56ac774b"}, ] [package.dependencies] @@ -559,13 +537,13 @@ dev = ["flake8", "markdown", "twine", "wheel"] [[package]] name = "griffe" -version = "0.38.0" +version = "0.40.1" description = "Signatures for entire Python programs. Extract the structure, the frame, the skeleton of your project, to generate API documentation or find breaking changes in your API." optional = false python-versions = ">=3.8" files = [ - {file = "griffe-0.38.0-py3-none-any.whl", hash = "sha256:6a5bc457320e8e199006aa5fbb03e162f5e21abe31aa6221f7a5c37ea0724c71"}, - {file = "griffe-0.38.0.tar.gz", hash = "sha256:9b97487b583042b543d1e28196caee638ecd766c8c4c98135071806cb5333ac2"}, + {file = "griffe-0.40.1-py3-none-any.whl", hash = "sha256:5b8c023f366fe273e762131fe4bfd141ea56c09b3cb825aa92d06a82681cfd93"}, + {file = "griffe-0.40.1.tar.gz", hash = "sha256:66c48a62e2ce5784b6940e603300fcfb807b6f099b94e7f753f1841661fd5c7c"}, ] [package.dependencies] @@ -573,13 +551,13 @@ colorama = ">=0.4" [[package]] name = "identify" -version = "2.5.32" +version = "2.5.34" description = "File identification library for Python" optional = false python-versions = ">=3.8" files = [ - {file = "identify-2.5.32-py2.py3-none-any.whl", hash = "sha256:0b7656ef6cba81664b783352c73f8c24b39cf82f926f78f4550eda928e5e0545"}, - {file = "identify-2.5.32.tar.gz", hash = "sha256:5d9979348ec1a21c768ae07e0a652924538e8bce67313a73cb0f681cf08ba407"}, + {file = "identify-2.5.34-py2.py3-none-any.whl", hash = "sha256:a4316013779e433d08b96e5eabb7f641e6c7942e4ab5d4c509ebd2e7a8994aed"}, + {file = "identify-2.5.34.tar.gz", hash = "sha256:ee17bc9d499899bc9eaec1ac7bf2dc9eedd480db9d88b96d123d3b64a9d34f5d"}, ] [package.extras] @@ -598,20 +576,20 @@ files = [ [[package]] name = "importlib-metadata" -version = "6.8.0" +version = "7.0.1" description = "Read metadata from Python packages" optional = false python-versions = ">=3.8" files = [ - {file = "importlib_metadata-6.8.0-py3-none-any.whl", hash = "sha256:3ebb78df84a805d7698245025b975d9d67053cd94c79245ba4b3eb694abe68bb"}, - {file = "importlib_metadata-6.8.0.tar.gz", hash = "sha256:dbace7892d8c0c4ac1ad096662232f831d4e64f4c4545bd53016a3e9d4654743"}, + {file = "importlib_metadata-7.0.1-py3-none-any.whl", hash = "sha256:4805911c3a4ec7c3966410053e9ec6a1fecd629117df5adee56dfc9432a1081e"}, + {file = "importlib_metadata-7.0.1.tar.gz", hash = "sha256:f238736bb06590ae52ac1fab06a3a9ef1d8dce2b7a35b5ab329371d6c8f5d2cc"}, ] [package.dependencies] zipp = ">=0.5" [package.extras] -docs = ["furo", "jaraco.packaging (>=9)", "jaraco.tidelift (>=1.4)", "rst.linker (>=1.9)", "sphinx (>=3.5)", "sphinx-lint"] +docs = ["furo", "jaraco.packaging (>=9.3)", "jaraco.tidelift (>=1.4)", "rst.linker (>=1.9)", "sphinx (<7.2.5)", "sphinx (>=3.5)", "sphinx-lint"] perf = ["ipython"] testing = ["flufl.flake8", "importlib-resources (>=1.3)", "packaging", "pyfakefs", "pytest (>=6)", "pytest-black (>=0.3.7)", "pytest-checkdocs (>=2.4)", "pytest-cov", "pytest-enabler (>=2.2)", "pytest-mypy (>=0.9.1)", "pytest-perf (>=0.9.2)", "pytest-ruff"] @@ -626,23 +604,6 @@ files = [ {file = "iniconfig-2.0.0.tar.gz", hash = "sha256:2d91e135bf72d31a410b17c16da610a82cb55f6b0477d1a902134b24a455b8b3"}, ] -[[package]] -name = "isort" -version = "5.12.0" -description = "A Python utility / library to sort Python imports." -optional = false -python-versions = ">=3.8.0" -files = [ - {file = "isort-5.12.0-py3-none-any.whl", hash = "sha256:f84c2818376e66cf843d497486ea8fed8700b340f308f076c6fb1229dff318b6"}, - {file = "isort-5.12.0.tar.gz", hash = "sha256:8bef7dde241278824a6d83f44a544709b065191b95b6e50894bdc722fcba0504"}, -] - -[package.extras] -colors = ["colorama (>=0.4.3)"] -pipfile-deprecated-finder = ["pip-shims (>=0.5.2)", "pipreqs", "requirementslib"] -plugins = ["setuptools"] -requirements-deprecated-finder = ["pip-api", "pipreqs"] - [[package]] name = "itsdangerous" version = "2.1.2" @@ -656,13 +617,13 @@ files = [ [[package]] name = "jinja2" -version = "3.1.2" +version = "3.1.3" description = "A very fast and expressive template engine." optional = false python-versions = ">=3.7" files = [ - {file = "Jinja2-3.1.2-py3-none-any.whl", hash = "sha256:6088930bfe239f0e6710546ab9c19c9ef35e29792895fed6e6e31a023a182a61"}, - {file = "Jinja2-3.1.2.tar.gz", hash = "sha256:31351a702a408a9e7595a8fc6150fc3f43bb6bf7e319770cbc0db9df9437e852"}, + {file = "Jinja2-3.1.3-py3-none-any.whl", hash = "sha256:7d6d50dd97d52cbc355597bd845fabfbac3f551e1f99619e39a35ce8c370b5fa"}, + {file = "Jinja2-3.1.3.tar.gz", hash = "sha256:ac8bd6544d4bb2c9792bf3a159e80bba8fda7f07e81bc3aed565432d5925ba90"}, ] [package.dependencies] @@ -673,17 +634,17 @@ i18n = ["Babel (>=2.7)"] [[package]] name = "jwskate" -version = "0.10.0" +version = "0.11.1" description = "A Pythonic implementation of the JOSE / JSON Web Crypto related RFCs (JWS, JWK, JWA, JWT, JWE)" optional = false python-versions = ">=3.8" files = [ - {file = "jwskate-0.10.0-py3-none-any.whl", hash = "sha256:c9ffcefe0e4bb04d2ef7251fbd26edbdf2bbe766e88447b35ed70abda7e0b319"}, - {file = "jwskate-0.10.0.tar.gz", hash = "sha256:9412043092786c6e029931427ec7dd01503802c4d4867846febdc4a704a12400"}, + {file = "jwskate-0.11.1-py3-none-any.whl", hash = "sha256:cdfa04fac10366afab08c20d2f75d1c6b57dc7d099b407b8fb4318349272f933"}, + {file = "jwskate-0.11.1.tar.gz", hash = "sha256:35354b487c8e835fdd57befea5e93e9e52fe25869d884fc764511d22061e6685"}, ] [package.dependencies] -binapy = ">=0.7" +binapy = ">=0.8" cryptography = ">=3.4" typing-extensions = ">=4.3" @@ -704,13 +665,13 @@ tornado = {version = "*", markers = "python_version > \"2.7\""} [[package]] name = "markdown" -version = "3.5.1" +version = "3.5.2" description = "Python implementation of John Gruber's Markdown." optional = false python-versions = ">=3.8" files = [ - {file = "Markdown-3.5.1-py3-none-any.whl", hash = "sha256:5874b47d4ee3f0b14d764324d2c94c03ea66bee56f2d929da9f2508d65e722dc"}, - {file = "Markdown-3.5.1.tar.gz", hash = "sha256:b65d7beb248dc22f2e8a31fb706d93798093c308dc1aba295aedeb9d41a813bd"}, + {file = "Markdown-3.5.2-py3-none-any.whl", hash = "sha256:d43323865d89fc0cb9b20c75fc8ad313af307cc087e84b657d9eec768eddeadd"}, + {file = "Markdown-3.5.2.tar.gz", hash = "sha256:e1ac7b3dc550ee80e602e71c1d168002f062e49f1b11e26a36264dafd4df2ef8"}, ] [package.dependencies] @@ -722,61 +683,71 @@ testing = ["coverage", "pyyaml"] [[package]] name = "markupsafe" -version = "2.1.3" +version = "2.1.5" description = "Safely add untrusted strings to HTML/XML markup." optional = false python-versions = ">=3.7" files = [ - {file = "MarkupSafe-2.1.3-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:cd0f502fe016460680cd20aaa5a76d241d6f35a1c3350c474bac1273803893fa"}, - {file = "MarkupSafe-2.1.3-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:e09031c87a1e51556fdcb46e5bd4f59dfb743061cf93c4d6831bf894f125eb57"}, - {file = "MarkupSafe-2.1.3-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:68e78619a61ecf91e76aa3e6e8e33fc4894a2bebe93410754bd28fce0a8a4f9f"}, - {file = "MarkupSafe-2.1.3-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:65c1a9bcdadc6c28eecee2c119465aebff8f7a584dd719facdd9e825ec61ab52"}, - {file = "MarkupSafe-2.1.3-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:525808b8019e36eb524b8c68acdd63a37e75714eac50e988180b169d64480a00"}, - {file = "MarkupSafe-2.1.3-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:962f82a3086483f5e5f64dbad880d31038b698494799b097bc59c2edf392fce6"}, - {file = "MarkupSafe-2.1.3-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:aa7bd130efab1c280bed0f45501b7c8795f9fdbeb02e965371bbef3523627779"}, - {file = "MarkupSafe-2.1.3-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:c9c804664ebe8f83a211cace637506669e7890fec1b4195b505c214e50dd4eb7"}, - {file = "MarkupSafe-2.1.3-cp310-cp310-win32.whl", hash = "sha256:10bbfe99883db80bdbaff2dcf681dfc6533a614f700da1287707e8a5d78a8431"}, - {file = "MarkupSafe-2.1.3-cp310-cp310-win_amd64.whl", hash = "sha256:1577735524cdad32f9f694208aa75e422adba74f1baee7551620e43a3141f559"}, - {file = "MarkupSafe-2.1.3-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:ad9e82fb8f09ade1c3e1b996a6337afac2b8b9e365f926f5a61aacc71adc5b3c"}, - {file = "MarkupSafe-2.1.3-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:3c0fae6c3be832a0a0473ac912810b2877c8cb9d76ca48de1ed31e1c68386575"}, - {file = "MarkupSafe-2.1.3-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b076b6226fb84157e3f7c971a47ff3a679d837cf338547532ab866c57930dbee"}, - {file = "MarkupSafe-2.1.3-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bfce63a9e7834b12b87c64d6b155fdd9b3b96191b6bd334bf37db7ff1fe457f2"}, - {file = "MarkupSafe-2.1.3-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:338ae27d6b8745585f87218a3f23f1512dbf52c26c28e322dbe54bcede54ccb9"}, - {file = "MarkupSafe-2.1.3-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:e4dd52d80b8c83fdce44e12478ad2e85c64ea965e75d66dbeafb0a3e77308fcc"}, - {file = "MarkupSafe-2.1.3-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:df0be2b576a7abbf737b1575f048c23fb1d769f267ec4358296f31c2479db8f9"}, - {file = "MarkupSafe-2.1.3-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:5bbe06f8eeafd38e5d0a4894ffec89378b6c6a625ff57e3028921f8ff59318ac"}, - {file = "MarkupSafe-2.1.3-cp311-cp311-win32.whl", hash = "sha256:dd15ff04ffd7e05ffcb7fe79f1b98041b8ea30ae9234aed2a9168b5797c3effb"}, - {file = "MarkupSafe-2.1.3-cp311-cp311-win_amd64.whl", hash = "sha256:134da1eca9ec0ae528110ccc9e48041e0828d79f24121a1a146161103c76e686"}, - {file = "MarkupSafe-2.1.3-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:8e254ae696c88d98da6555f5ace2279cf7cd5b3f52be2b5cf97feafe883b58d2"}, - {file = "MarkupSafe-2.1.3-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:cb0932dc158471523c9637e807d9bfb93e06a95cbf010f1a38b98623b929ef2b"}, - {file = "MarkupSafe-2.1.3-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9402b03f1a1b4dc4c19845e5c749e3ab82d5078d16a2a4c2cd2df62d57bb0707"}, - {file = "MarkupSafe-2.1.3-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ca379055a47383d02a5400cb0d110cef0a776fc644cda797db0c5696cfd7e18e"}, - {file = "MarkupSafe-2.1.3-cp37-cp37m-musllinux_1_1_aarch64.whl", hash = "sha256:b7ff0f54cb4ff66dd38bebd335a38e2c22c41a8ee45aa608efc890ac3e3931bc"}, - {file = "MarkupSafe-2.1.3-cp37-cp37m-musllinux_1_1_i686.whl", hash = "sha256:c011a4149cfbcf9f03994ec2edffcb8b1dc2d2aede7ca243746df97a5d41ce48"}, - {file = "MarkupSafe-2.1.3-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:56d9f2ecac662ca1611d183feb03a3fa4406469dafe241673d521dd5ae92a155"}, - {file = "MarkupSafe-2.1.3-cp37-cp37m-win32.whl", hash = "sha256:8758846a7e80910096950b67071243da3e5a20ed2546e6392603c096778d48e0"}, - {file = "MarkupSafe-2.1.3-cp37-cp37m-win_amd64.whl", hash = "sha256:787003c0ddb00500e49a10f2844fac87aa6ce977b90b0feaaf9de23c22508b24"}, - {file = "MarkupSafe-2.1.3-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:2ef12179d3a291be237280175b542c07a36e7f60718296278d8593d21ca937d4"}, - {file = "MarkupSafe-2.1.3-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:2c1b19b3aaacc6e57b7e25710ff571c24d6c3613a45e905b1fde04d691b98ee0"}, - {file = "MarkupSafe-2.1.3-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8afafd99945ead6e075b973fefa56379c5b5c53fd8937dad92c662da5d8fd5ee"}, - {file = "MarkupSafe-2.1.3-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8c41976a29d078bb235fea9b2ecd3da465df42a562910f9022f1a03107bd02be"}, - {file = "MarkupSafe-2.1.3-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d080e0a5eb2529460b30190fcfcc4199bd7f827663f858a226a81bc27beaa97e"}, - {file = "MarkupSafe-2.1.3-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:69c0f17e9f5a7afdf2cc9fb2d1ce6aabdb3bafb7f38017c0b77862bcec2bbad8"}, - {file = "MarkupSafe-2.1.3-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:504b320cd4b7eff6f968eddf81127112db685e81f7e36e75f9f84f0df46041c3"}, - {file = "MarkupSafe-2.1.3-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:42de32b22b6b804f42c5d98be4f7e5e977ecdd9ee9b660fda1a3edf03b11792d"}, - {file = "MarkupSafe-2.1.3-cp38-cp38-win32.whl", hash = "sha256:ceb01949af7121f9fc39f7d27f91be8546f3fb112c608bc4029aef0bab86a2a5"}, - {file = "MarkupSafe-2.1.3-cp38-cp38-win_amd64.whl", hash = "sha256:1b40069d487e7edb2676d3fbdb2b0829ffa2cd63a2ec26c4938b2d34391b4ecc"}, - {file = "MarkupSafe-2.1.3-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:8023faf4e01efadfa183e863fefde0046de576c6f14659e8782065bcece22198"}, - {file = "MarkupSafe-2.1.3-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:6b2b56950d93e41f33b4223ead100ea0fe11f8e6ee5f641eb753ce4b77a7042b"}, - {file = "MarkupSafe-2.1.3-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9dcdfd0eaf283af041973bff14a2e143b8bd64e069f4c383416ecd79a81aab58"}, - {file = "MarkupSafe-2.1.3-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:05fb21170423db021895e1ea1e1f3ab3adb85d1c2333cbc2310f2a26bc77272e"}, - {file = "MarkupSafe-2.1.3-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:282c2cb35b5b673bbcadb33a585408104df04f14b2d9b01d4c345a3b92861c2c"}, - {file = "MarkupSafe-2.1.3-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:ab4a0df41e7c16a1392727727e7998a467472d0ad65f3ad5e6e765015df08636"}, - {file = "MarkupSafe-2.1.3-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:7ef3cb2ebbf91e330e3bb937efada0edd9003683db6b57bb108c4001f37a02ea"}, - {file = "MarkupSafe-2.1.3-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:0a4e4a1aff6c7ac4cd55792abf96c915634c2b97e3cc1c7129578aa68ebd754e"}, - {file = "MarkupSafe-2.1.3-cp39-cp39-win32.whl", hash = "sha256:fec21693218efe39aa7f8599346e90c705afa52c5b31ae019b2e57e8f6542bb2"}, - {file = "MarkupSafe-2.1.3-cp39-cp39-win_amd64.whl", hash = "sha256:3fd4abcb888d15a94f32b75d8fd18ee162ca0c064f35b11134be77050296d6ba"}, - {file = "MarkupSafe-2.1.3.tar.gz", hash = "sha256:af598ed32d6ae86f1b747b82783958b1a4ab8f617b06fe68795c7f026abbdcad"}, + {file = "MarkupSafe-2.1.5-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:a17a92de5231666cfbe003f0e4b9b3a7ae3afb1ec2845aadc2bacc93ff85febc"}, + {file = "MarkupSafe-2.1.5-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:72b6be590cc35924b02c78ef34b467da4ba07e4e0f0454a2c5907f473fc50ce5"}, + {file = "MarkupSafe-2.1.5-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e61659ba32cf2cf1481e575d0462554625196a1f2fc06a1c777d3f48e8865d46"}, + {file = "MarkupSafe-2.1.5-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2174c595a0d73a3080ca3257b40096db99799265e1c27cc5a610743acd86d62f"}, + {file = "MarkupSafe-2.1.5-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ae2ad8ae6ebee9d2d94b17fb62763125f3f374c25618198f40cbb8b525411900"}, + {file = "MarkupSafe-2.1.5-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:075202fa5b72c86ad32dc7d0b56024ebdbcf2048c0ba09f1cde31bfdd57bcfff"}, + {file = "MarkupSafe-2.1.5-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:598e3276b64aff0e7b3451b72e94fa3c238d452e7ddcd893c3ab324717456bad"}, + {file = "MarkupSafe-2.1.5-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:fce659a462a1be54d2ffcacea5e3ba2d74daa74f30f5f143fe0c58636e355fdd"}, + {file = "MarkupSafe-2.1.5-cp310-cp310-win32.whl", hash = "sha256:d9fad5155d72433c921b782e58892377c44bd6252b5af2f67f16b194987338a4"}, + {file = "MarkupSafe-2.1.5-cp310-cp310-win_amd64.whl", hash = "sha256:bf50cd79a75d181c9181df03572cdce0fbb75cc353bc350712073108cba98de5"}, + {file = "MarkupSafe-2.1.5-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:629ddd2ca402ae6dbedfceeba9c46d5f7b2a61d9749597d4307f943ef198fc1f"}, + {file = "MarkupSafe-2.1.5-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:5b7b716f97b52c5a14bffdf688f971b2d5ef4029127f1ad7a513973cfd818df2"}, + {file = "MarkupSafe-2.1.5-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6ec585f69cec0aa07d945b20805be741395e28ac1627333b1c5b0105962ffced"}, + {file = "MarkupSafe-2.1.5-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b91c037585eba9095565a3556f611e3cbfaa42ca1e865f7b8015fe5c7336d5a5"}, + {file = "MarkupSafe-2.1.5-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:7502934a33b54030eaf1194c21c692a534196063db72176b0c4028e140f8f32c"}, + {file = "MarkupSafe-2.1.5-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:0e397ac966fdf721b2c528cf028494e86172b4feba51d65f81ffd65c63798f3f"}, + {file = "MarkupSafe-2.1.5-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:c061bb86a71b42465156a3ee7bd58c8c2ceacdbeb95d05a99893e08b8467359a"}, + {file = "MarkupSafe-2.1.5-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:3a57fdd7ce31c7ff06cdfbf31dafa96cc533c21e443d57f5b1ecc6cdc668ec7f"}, + {file = "MarkupSafe-2.1.5-cp311-cp311-win32.whl", hash = "sha256:397081c1a0bfb5124355710fe79478cdbeb39626492b15d399526ae53422b906"}, + {file = "MarkupSafe-2.1.5-cp311-cp311-win_amd64.whl", hash = "sha256:2b7c57a4dfc4f16f7142221afe5ba4e093e09e728ca65c51f5620c9aaeb9a617"}, + {file = "MarkupSafe-2.1.5-cp312-cp312-macosx_10_9_universal2.whl", hash = "sha256:8dec4936e9c3100156f8a2dc89c4b88d5c435175ff03413b443469c7c8c5f4d1"}, + {file = "MarkupSafe-2.1.5-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:3c6b973f22eb18a789b1460b4b91bf04ae3f0c4234a0a6aa6b0a92f6f7b951d4"}, + {file = "MarkupSafe-2.1.5-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ac07bad82163452a6884fe8fa0963fb98c2346ba78d779ec06bd7a6262132aee"}, + {file = "MarkupSafe-2.1.5-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f5dfb42c4604dddc8e4305050aa6deb084540643ed5804d7455b5df8fe16f5e5"}, + {file = "MarkupSafe-2.1.5-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ea3d8a3d18833cf4304cd2fc9cbb1efe188ca9b5efef2bdac7adc20594a0e46b"}, + {file = "MarkupSafe-2.1.5-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:d050b3361367a06d752db6ead6e7edeb0009be66bc3bae0ee9d97fb326badc2a"}, + {file = "MarkupSafe-2.1.5-cp312-cp312-musllinux_1_1_i686.whl", hash = "sha256:bec0a414d016ac1a18862a519e54b2fd0fc8bbfd6890376898a6c0891dd82e9f"}, + {file = "MarkupSafe-2.1.5-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:58c98fee265677f63a4385256a6d7683ab1832f3ddd1e66fe948d5880c21a169"}, + {file = "MarkupSafe-2.1.5-cp312-cp312-win32.whl", hash = "sha256:8590b4ae07a35970728874632fed7bd57b26b0102df2d2b233b6d9d82f6c62ad"}, + {file = "MarkupSafe-2.1.5-cp312-cp312-win_amd64.whl", hash = "sha256:823b65d8706e32ad2df51ed89496147a42a2a6e01c13cfb6ffb8b1e92bc910bb"}, + {file = "MarkupSafe-2.1.5-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:c8b29db45f8fe46ad280a7294f5c3ec36dbac9491f2d1c17345be8e69cc5928f"}, + {file = "MarkupSafe-2.1.5-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ec6a563cff360b50eed26f13adc43e61bc0c04d94b8be985e6fb24b81f6dcfdf"}, + {file = "MarkupSafe-2.1.5-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a549b9c31bec33820e885335b451286e2969a2d9e24879f83fe904a5ce59d70a"}, + {file = "MarkupSafe-2.1.5-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:4f11aa001c540f62c6166c7726f71f7573b52c68c31f014c25cc7901deea0b52"}, + {file = "MarkupSafe-2.1.5-cp37-cp37m-musllinux_1_1_aarch64.whl", hash = "sha256:7b2e5a267c855eea6b4283940daa6e88a285f5f2a67f2220203786dfa59b37e9"}, + {file = "MarkupSafe-2.1.5-cp37-cp37m-musllinux_1_1_i686.whl", hash = "sha256:2d2d793e36e230fd32babe143b04cec8a8b3eb8a3122d2aceb4a371e6b09b8df"}, + {file = "MarkupSafe-2.1.5-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:ce409136744f6521e39fd8e2a24c53fa18ad67aa5bc7c2cf83645cce5b5c4e50"}, + {file = "MarkupSafe-2.1.5-cp37-cp37m-win32.whl", hash = "sha256:4096e9de5c6fdf43fb4f04c26fb114f61ef0bf2e5604b6ee3019d51b69e8c371"}, + {file = "MarkupSafe-2.1.5-cp37-cp37m-win_amd64.whl", hash = "sha256:4275d846e41ecefa46e2015117a9f491e57a71ddd59bbead77e904dc02b1bed2"}, + {file = "MarkupSafe-2.1.5-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:656f7526c69fac7f600bd1f400991cc282b417d17539a1b228617081106feb4a"}, + {file = "MarkupSafe-2.1.5-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:97cafb1f3cbcd3fd2b6fbfb99ae11cdb14deea0736fc2b0952ee177f2b813a46"}, + {file = "MarkupSafe-2.1.5-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1f3fbcb7ef1f16e48246f704ab79d79da8a46891e2da03f8783a5b6fa41a9532"}, + {file = "MarkupSafe-2.1.5-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fa9db3f79de01457b03d4f01b34cf91bc0048eb2c3846ff26f66687c2f6d16ab"}, + {file = "MarkupSafe-2.1.5-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ffee1f21e5ef0d712f9033568f8344d5da8cc2869dbd08d87c84656e6a2d2f68"}, + {file = "MarkupSafe-2.1.5-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:5dedb4db619ba5a2787a94d877bc8ffc0566f92a01c0ef214865e54ecc9ee5e0"}, + {file = "MarkupSafe-2.1.5-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:30b600cf0a7ac9234b2638fbc0fb6158ba5bdcdf46aeb631ead21248b9affbc4"}, + {file = "MarkupSafe-2.1.5-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:8dd717634f5a044f860435c1d8c16a270ddf0ef8588d4887037c5028b859b0c3"}, + {file = "MarkupSafe-2.1.5-cp38-cp38-win32.whl", hash = "sha256:daa4ee5a243f0f20d528d939d06670a298dd39b1ad5f8a72a4275124a7819eff"}, + {file = "MarkupSafe-2.1.5-cp38-cp38-win_amd64.whl", hash = "sha256:619bc166c4f2de5caa5a633b8b7326fbe98e0ccbfacabd87268a2b15ff73a029"}, + {file = "MarkupSafe-2.1.5-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:7a68b554d356a91cce1236aa7682dc01df0edba8d043fd1ce607c49dd3c1edcf"}, + {file = "MarkupSafe-2.1.5-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:db0b55e0f3cc0be60c1f19efdde9a637c32740486004f20d1cff53c3c0ece4d2"}, + {file = "MarkupSafe-2.1.5-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3e53af139f8579a6d5f7b76549125f0d94d7e630761a2111bc431fd820e163b8"}, + {file = "MarkupSafe-2.1.5-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:17b950fccb810b3293638215058e432159d2b71005c74371d784862b7e4683f3"}, + {file = "MarkupSafe-2.1.5-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:4c31f53cdae6ecfa91a77820e8b151dba54ab528ba65dfd235c80b086d68a465"}, + {file = "MarkupSafe-2.1.5-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:bff1b4290a66b490a2f4719358c0cdcd9bafb6b8f061e45c7a2460866bf50c2e"}, + {file = "MarkupSafe-2.1.5-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:bc1667f8b83f48511b94671e0e441401371dfd0f0a795c7daa4a3cd1dde55bea"}, + {file = "MarkupSafe-2.1.5-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:5049256f536511ee3f7e1b3f87d1d1209d327e818e6ae1365e8653d7e3abb6a6"}, + {file = "MarkupSafe-2.1.5-cp39-cp39-win32.whl", hash = "sha256:00e046b6dd71aa03a41079792f8473dc494d564611a8f89bbbd7cb93295ebdcf"}, + {file = "MarkupSafe-2.1.5-cp39-cp39-win_amd64.whl", hash = "sha256:fa173ec60341d6bb97a89f5ea19c85c5643c1e7dedebc22f5181eb73573142c5"}, + {file = "MarkupSafe-2.1.5.tar.gz", hash = "sha256:d283d37a890ba4c1ae73ffadf8046435c76e7bc2247bbb63c00bd1a709c6544b"}, ] [[package]] @@ -836,15 +807,33 @@ files = [ Markdown = ">=3.3" mkdocs = ">=1.1" +[[package]] +name = "mkdocs-include-markdown-plugin" +version = "6.0.4" +description = "Mkdocs Markdown includer plugin." +optional = false +python-versions = ">=3.8" +files = [ + {file = "mkdocs_include_markdown_plugin-6.0.4-py3-none-any.whl", hash = "sha256:e7b8b5ecc41d6a3e16969cff3725ec3a391b68e9dfe1a4b4e36a8508becda835"}, + {file = "mkdocs_include_markdown_plugin-6.0.4.tar.gz", hash = "sha256:523c9c3a1d6a517386dc11bf60b0c0c564af1071bb6de8d213106d54f752dcc1"}, +] + +[package.dependencies] +mkdocs = ">=1.4" +wcmatch = ">=8,<9" + +[package.extras] +cache = ["platformdirs"] + [[package]] name = "mkdocs-material" -version = "9.4.14" +version = "9.5.9" description = "Documentation that simply works" optional = false python-versions = ">=3.8" files = [ - {file = "mkdocs_material-9.4.14-py3-none-any.whl", hash = "sha256:dbc78a4fea97b74319a6aa9a2f0be575a6028be6958f813ba367188f7b8428f6"}, - {file = "mkdocs_material-9.4.14.tar.gz", hash = "sha256:a511d3ff48fa8718b033e7e37d17abd9cc1de0fdf0244a625ca2ae2387e2416d"}, + {file = "mkdocs_material-9.5.9-py3-none-any.whl", hash = "sha256:a5d62b73b3b74349e45472bfadc129c871dd2d4add68d84819580597b2f50d5d"}, + {file = "mkdocs_material-9.5.9.tar.gz", hash = "sha256:635df543c01c25c412d6c22991872267723737d5a2f062490f33b2da1c013c6d"}, ] [package.dependencies] @@ -852,7 +841,7 @@ babel = ">=2.10,<3.0" colorama = ">=0.4,<1.0" jinja2 = ">=3.0,<4.0" markdown = ">=3.2,<4.0" -mkdocs = ">=1.5.3,<2.0" +mkdocs = ">=1.5.3,<1.6.0" mkdocs-material-extensions = ">=1.3,<2.0" paginate = ">=0.5,<1.0" pygments = ">=2.16,<3.0" @@ -861,8 +850,8 @@ regex = ">=2022.4" requests = ">=2.26,<3.0" [package.extras] -git = ["mkdocs-git-committers-plugin-2 (>=1.1,<2.0)", "mkdocs-git-revision-date-localized-plugin (>=1.2,<2.0)"] -imaging = ["cairosvg (>=2.6,<3.0)", "pillow (>=9.4,<10.0)"] +git = ["mkdocs-git-committers-plugin-2 (>=1.1,<2.0)", "mkdocs-git-revision-date-localized-plugin (>=1.2.4,<2.0)"] +imaging = ["cairosvg (>=2.6,<3.0)", "pillow (>=10.2,<11.0)"] recommended = ["mkdocs-minify-plugin (>=0.7,<1.0)", "mkdocs-redirects (>=1.2,<2.0)", "mkdocs-rss-plugin (>=1.6,<2.0)"] [[package]] @@ -907,13 +896,13 @@ python-legacy = ["mkdocstrings-python-legacy (>=0.2.1)"] [[package]] name = "mkdocstrings-python" -version = "1.7.5" +version = "1.8.0" description = "A Python handler for mkdocstrings." optional = false python-versions = ">=3.8" files = [ - {file = "mkdocstrings_python-1.7.5-py3-none-any.whl", hash = "sha256:5f6246026353f0c0785135db70c3fe9a5d9318990fc7ceb11d62097b8ffdd704"}, - {file = "mkdocstrings_python-1.7.5.tar.gz", hash = "sha256:c7d143728257dbf1aa550446555a554b760dcd40a763f077189d298502b800be"}, + {file = "mkdocstrings_python-1.8.0-py3-none-any.whl", hash = "sha256:4209970cc90bec194568682a535848a8d8489516c6ed4adbe58bbc67b699ca9d"}, + {file = "mkdocstrings_python-1.8.0.tar.gz", hash = "sha256:1488bddf50ee42c07d9a488dddc197f8e8999c2899687043ec5dd1643d057192"}, ] [package.dependencies] @@ -922,38 +911,38 @@ mkdocstrings = ">=0.20" [[package]] name = "mypy" -version = "1.7.1" +version = "1.8.0" description = "Optional static typing for Python" optional = false python-versions = ">=3.8" files = [ - {file = "mypy-1.7.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:12cce78e329838d70a204293e7b29af9faa3ab14899aec397798a4b41be7f340"}, - {file = "mypy-1.7.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:1484b8fa2c10adf4474f016e09d7a159602f3239075c7bf9f1627f5acf40ad49"}, - {file = "mypy-1.7.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:31902408f4bf54108bbfb2e35369877c01c95adc6192958684473658c322c8a5"}, - {file = "mypy-1.7.1-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:f2c2521a8e4d6d769e3234350ba7b65ff5d527137cdcde13ff4d99114b0c8e7d"}, - {file = "mypy-1.7.1-cp310-cp310-win_amd64.whl", hash = "sha256:fcd2572dd4519e8a6642b733cd3a8cfc1ef94bafd0c1ceed9c94fe736cb65b6a"}, - {file = "mypy-1.7.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:4b901927f16224d0d143b925ce9a4e6b3a758010673eeded9b748f250cf4e8f7"}, - {file = "mypy-1.7.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:2f7f6985d05a4e3ce8255396df363046c28bea790e40617654e91ed580ca7c51"}, - {file = "mypy-1.7.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:944bdc21ebd620eafefc090cdf83158393ec2b1391578359776c00de00e8907a"}, - {file = "mypy-1.7.1-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:9c7ac372232c928fff0645d85f273a726970c014749b924ce5710d7d89763a28"}, - {file = "mypy-1.7.1-cp311-cp311-win_amd64.whl", hash = "sha256:f6efc9bd72258f89a3816e3a98c09d36f079c223aa345c659622f056b760ab42"}, - {file = "mypy-1.7.1-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:6dbdec441c60699288adf051f51a5d512b0d818526d1dcfff5a41f8cd8b4aaf1"}, - {file = "mypy-1.7.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:4fc3d14ee80cd22367caaaf6e014494415bf440980a3045bf5045b525680ac33"}, - {file = "mypy-1.7.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2c6e4464ed5f01dc44dc9821caf67b60a4e5c3b04278286a85c067010653a0eb"}, - {file = "mypy-1.7.1-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:d9b338c19fa2412f76e17525c1b4f2c687a55b156320acb588df79f2e6fa9fea"}, - {file = "mypy-1.7.1-cp312-cp312-win_amd64.whl", hash = "sha256:204e0d6de5fd2317394a4eff62065614c4892d5a4d1a7ee55b765d7a3d9e3f82"}, - {file = "mypy-1.7.1-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:84860e06ba363d9c0eeabd45ac0fde4b903ad7aa4f93cd8b648385a888e23200"}, - {file = "mypy-1.7.1-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:8c5091ebd294f7628eb25ea554852a52058ac81472c921150e3a61cdd68f75a7"}, - {file = "mypy-1.7.1-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:40716d1f821b89838589e5b3106ebbc23636ffdef5abc31f7cd0266db936067e"}, - {file = "mypy-1.7.1-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:5cf3f0c5ac72139797953bd50bc6c95ac13075e62dbfcc923571180bebb662e9"}, - {file = "mypy-1.7.1-cp38-cp38-win_amd64.whl", hash = "sha256:78e25b2fd6cbb55ddfb8058417df193f0129cad5f4ee75d1502248e588d9e0d7"}, - {file = "mypy-1.7.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:75c4d2a6effd015786c87774e04331b6da863fc3fc4e8adfc3b40aa55ab516fe"}, - {file = "mypy-1.7.1-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:2643d145af5292ee956aa0a83c2ce1038a3bdb26e033dadeb2f7066fb0c9abce"}, - {file = "mypy-1.7.1-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:75aa828610b67462ffe3057d4d8a4112105ed211596b750b53cbfe182f44777a"}, - {file = "mypy-1.7.1-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:ee5d62d28b854eb61889cde4e1dbc10fbaa5560cb39780c3995f6737f7e82120"}, - {file = "mypy-1.7.1-cp39-cp39-win_amd64.whl", hash = "sha256:72cf32ce7dd3562373f78bd751f73c96cfb441de147cc2448a92c1a308bd0ca6"}, - {file = "mypy-1.7.1-py3-none-any.whl", hash = "sha256:f7c5d642db47376a0cc130f0de6d055056e010debdaf0707cd2b0fc7e7ef30ea"}, - {file = "mypy-1.7.1.tar.gz", hash = "sha256:fcb6d9afb1b6208b4c712af0dafdc650f518836065df0d4fb1d800f5d6773db2"}, + {file = "mypy-1.8.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:485a8942f671120f76afffff70f259e1cd0f0cfe08f81c05d8816d958d4577d3"}, + {file = "mypy-1.8.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:df9824ac11deaf007443e7ed2a4a26bebff98d2bc43c6da21b2b64185da011c4"}, + {file = "mypy-1.8.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2afecd6354bbfb6e0160f4e4ad9ba6e4e003b767dd80d85516e71f2e955ab50d"}, + {file = "mypy-1.8.0-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:8963b83d53ee733a6e4196954502b33567ad07dfd74851f32be18eb932fb1cb9"}, + {file = "mypy-1.8.0-cp310-cp310-win_amd64.whl", hash = "sha256:e46f44b54ebddbeedbd3d5b289a893219065ef805d95094d16a0af6630f5d410"}, + {file = "mypy-1.8.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:855fe27b80375e5c5878492f0729540db47b186509c98dae341254c8f45f42ae"}, + {file = "mypy-1.8.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4c886c6cce2d070bd7df4ec4a05a13ee20c0aa60cb587e8d1265b6c03cf91da3"}, + {file = "mypy-1.8.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d19c413b3c07cbecf1f991e2221746b0d2a9410b59cb3f4fb9557f0365a1a817"}, + {file = "mypy-1.8.0-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:9261ed810972061388918c83c3f5cd46079d875026ba97380f3e3978a72f503d"}, + {file = "mypy-1.8.0-cp311-cp311-win_amd64.whl", hash = "sha256:51720c776d148bad2372ca21ca29256ed483aa9a4cdefefcef49006dff2a6835"}, + {file = "mypy-1.8.0-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:52825b01f5c4c1c4eb0db253ec09c7aa17e1a7304d247c48b6f3599ef40db8bd"}, + {file = "mypy-1.8.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:f5ac9a4eeb1ec0f1ccdc6f326bcdb464de5f80eb07fb38b5ddd7b0de6bc61e55"}, + {file = "mypy-1.8.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:afe3fe972c645b4632c563d3f3eff1cdca2fa058f730df2b93a35e3b0c538218"}, + {file = "mypy-1.8.0-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:42c6680d256ab35637ef88891c6bd02514ccb7e1122133ac96055ff458f93fc3"}, + {file = "mypy-1.8.0-cp312-cp312-win_amd64.whl", hash = "sha256:720a5ca70e136b675af3af63db533c1c8c9181314d207568bbe79051f122669e"}, + {file = "mypy-1.8.0-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:028cf9f2cae89e202d7b6593cd98db6759379f17a319b5faf4f9978d7084cdc6"}, + {file = "mypy-1.8.0-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:4e6d97288757e1ddba10dd9549ac27982e3e74a49d8d0179fc14d4365c7add66"}, + {file = "mypy-1.8.0-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7f1478736fcebb90f97e40aff11a5f253af890c845ee0c850fe80aa060a267c6"}, + {file = "mypy-1.8.0-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:42419861b43e6962a649068a61f4a4839205a3ef525b858377a960b9e2de6e0d"}, + {file = "mypy-1.8.0-cp38-cp38-win_amd64.whl", hash = "sha256:2b5b6c721bd4aabaadead3a5e6fa85c11c6c795e0c81a7215776ef8afc66de02"}, + {file = "mypy-1.8.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:5c1538c38584029352878a0466f03a8ee7547d7bd9f641f57a0f3017a7c905b8"}, + {file = "mypy-1.8.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:4ef4be7baf08a203170f29e89d79064463b7fc7a0908b9d0d5114e8009c3a259"}, + {file = "mypy-1.8.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7178def594014aa6c35a8ff411cf37d682f428b3b5617ca79029d8ae72f5402b"}, + {file = "mypy-1.8.0-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:ab3c84fa13c04aeeeabb2a7f67a25ef5d77ac9d6486ff33ded762ef353aa5592"}, + {file = "mypy-1.8.0-cp39-cp39-win_amd64.whl", hash = "sha256:99b00bc72855812a60d253420d8a2eae839b0afa4938f09f4d2aa9bb4654263a"}, + {file = "mypy-1.8.0-py3-none-any.whl", hash = "sha256:538fd81bb5e430cc1381a443971c0475582ff9f434c16cd46d2c66763ce85d9d"}, + {file = "mypy-1.8.0.tar.gz", hash = "sha256:6ff8b244d7085a0b425b56d327b480c3b29cafbd2eff27316a004f9a7391ae07"}, ] [package.dependencies] @@ -1029,39 +1018,39 @@ files = [ [[package]] name = "pathspec" -version = "0.11.2" +version = "0.12.1" description = "Utility library for gitignore style pattern matching of file paths." optional = false -python-versions = ">=3.7" +python-versions = ">=3.8" files = [ - {file = "pathspec-0.11.2-py3-none-any.whl", hash = "sha256:1d6ed233af05e679efb96b1851550ea95bbb64b7c490b0f5aa52996c11e92a20"}, - {file = "pathspec-0.11.2.tar.gz", hash = "sha256:e0d8d0ac2f12da61956eb2306b69f9469b42f4deb0f3cb6ed47b9cce9996ced3"}, + {file = "pathspec-0.12.1-py3-none-any.whl", hash = "sha256:a0d503e138a4c123b27490a4f7beda6a01c6f288df0e4a8b79c7eb0dc7b4cc08"}, + {file = "pathspec-0.12.1.tar.gz", hash = "sha256:a482d51503a1ab33b1c67a6c3813a26953dbdc71c31dacaef9a838c4e29f5712"}, ] [[package]] name = "platformdirs" -version = "4.0.0" +version = "4.2.0" description = "A small Python package for determining appropriate platform-specific dirs, e.g. a \"user data dir\"." optional = false -python-versions = ">=3.7" +python-versions = ">=3.8" files = [ - {file = "platformdirs-4.0.0-py3-none-any.whl", hash = "sha256:118c954d7e949b35437270383a3f2531e99dd93cf7ce4dc8340d3356d30f173b"}, - {file = "platformdirs-4.0.0.tar.gz", hash = "sha256:cb633b2bcf10c51af60beb0ab06d2f1d69064b43abf4c185ca6b28865f3f9731"}, + {file = "platformdirs-4.2.0-py3-none-any.whl", hash = "sha256:0614df2a2f37e1a662acbd8e2b25b92ccf8632929bc6d43467e17fe89c75e068"}, + {file = "platformdirs-4.2.0.tar.gz", hash = "sha256:ef0cc731df711022c174543cb70a9b5bd22e5a9337c8624ef2c2ceb8ddad8768"}, ] [package.extras] -docs = ["furo (>=2023.7.26)", "proselint (>=0.13)", "sphinx (>=7.1.1)", "sphinx-autodoc-typehints (>=1.24)"] -test = ["appdirs (==1.4.4)", "covdefaults (>=2.3)", "pytest (>=7.4)", "pytest-cov (>=4.1)", "pytest-mock (>=3.11.1)"] +docs = ["furo (>=2023.9.10)", "proselint (>=0.13)", "sphinx (>=7.2.6)", "sphinx-autodoc-typehints (>=1.25.2)"] +test = ["appdirs (==1.4.4)", "covdefaults (>=2.3)", "pytest (>=7.4.3)", "pytest-cov (>=4.1)", "pytest-mock (>=3.12)"] [[package]] name = "pluggy" -version = "1.3.0" +version = "1.4.0" description = "plugin and hook calling mechanisms for python" optional = false python-versions = ">=3.8" files = [ - {file = "pluggy-1.3.0-py3-none-any.whl", hash = "sha256:d89c696a773f8bd377d18e5ecda92b7a3793cbe66c87060a6fb58c7b6e1061f7"}, - {file = "pluggy-1.3.0.tar.gz", hash = "sha256:cf61ae8f126ac6f7c451172cf30e3e43d3ca77615509771b3a984a0730651e12"}, + {file = "pluggy-1.4.0-py3-none-any.whl", hash = "sha256:7db9f7b503d67d1c5b95f59773ebb58a8c1c288129a88665838012cfb07b8981"}, + {file = "pluggy-1.4.0.tar.gz", hash = "sha256:8c85c2876142a764e5b7548e7d9a0e0ddb46f5185161049a79b7e974454223be"}, ] [package.extras] @@ -1114,13 +1103,13 @@ windows-terminal = ["colorama (>=0.4.6)"] [[package]] name = "pymdown-extensions" -version = "10.5" +version = "10.7" description = "Extension pack for Python Markdown." optional = false python-versions = ">=3.8" files = [ - {file = "pymdown_extensions-10.5-py3-none-any.whl", hash = "sha256:1f0ca8bb5beff091315f793ee17683bc1390731f6ac4c5eb01e27464b80fe879"}, - {file = "pymdown_extensions-10.5.tar.gz", hash = "sha256:1b60f1e462adbec5a1ed79dac91f666c9c0d241fa294de1989f29d20096cfd0b"}, + {file = "pymdown_extensions-10.7-py3-none-any.whl", hash = "sha256:6ca215bc57bc12bf32b414887a68b810637d039124ed9b2e5bd3325cbb2c050c"}, + {file = "pymdown_extensions-10.7.tar.gz", hash = "sha256:c0d64d5cf62566f59e6b2b690a4095c931107c250a8c8e1351c1de5f6b036deb"}, ] [package.dependencies] @@ -1151,13 +1140,13 @@ testing = ["covdefaults (>=2.3)", "pytest (>=7.4)", "pytest-cov (>=4.1)", "pytes [[package]] name = "pytest" -version = "7.4.3" +version = "8.0.0" description = "pytest: simple powerful testing with Python" optional = false -python-versions = ">=3.7" +python-versions = ">=3.8" files = [ - {file = "pytest-7.4.3-py3-none-any.whl", hash = "sha256:0d009c083ea859a71b76adf7c1d502e4bc170b80a8ef002da5806527b9591fac"}, - {file = "pytest-7.4.3.tar.gz", hash = "sha256:d989d136982de4e3b29dabcc838ad581c64e8ed52c11fbe86ddebd9da0818cd5"}, + {file = "pytest-8.0.0-py3-none-any.whl", hash = "sha256:50fb9cbe836c3f20f0dfa99c565201fb75dc54c8d76373cd1bde06b06657bdb6"}, + {file = "pytest-8.0.0.tar.gz", hash = "sha256:249b1b0864530ba251b7438274c4d251c58d868edaaec8762893ad4a0d71c36c"}, ] [package.dependencies] @@ -1165,7 +1154,7 @@ colorama = {version = "*", markers = "sys_platform == \"win32\""} exceptiongroup = {version = ">=1.0.0rc8", markers = "python_version < \"3.11\""} iniconfig = "*" packaging = "*" -pluggy = ">=0.12,<2.0" +pluggy = ">=1.3.0,<2.0" tomli = {version = ">=1.0.0", markers = "python_version < \"3.11\""} [package.extras] @@ -1244,13 +1233,13 @@ six = ">=1.5" [[package]] name = "pytz" -version = "2023.3.post1" +version = "2024.1" description = "World timezone definitions, modern and historical" optional = false python-versions = "*" files = [ - {file = "pytz-2023.3.post1-py2.py3-none-any.whl", hash = "sha256:ce42d816b81b68506614c11e8937d3aa9e41007ceb50bfdcb0749b921bf646c7"}, - {file = "pytz-2023.3.post1.tar.gz", hash = "sha256:7b4fddbeb94a1eba4b557da24f19fdf9db575192544270a9101d8509f9f43d7b"}, + {file = "pytz-2024.1-py2.py3-none-any.whl", hash = "sha256:328171f4e3623139da4983451950b28e95ac706e13f3f2630a879749e7a8b319"}, + {file = "pytz-2024.1.tar.gz", hash = "sha256:2a29735ea9c18baf14b448846bde5a48030ed267578472d8955cd0e7443a9812"}, ] [[package]] @@ -1318,99 +1307,104 @@ pyyaml = "*" [[package]] name = "regex" -version = "2023.10.3" +version = "2023.12.25" description = "Alternative regular expression module, to replace re." optional = false python-versions = ">=3.7" files = [ - {file = "regex-2023.10.3-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:4c34d4f73ea738223a094d8e0ffd6d2c1a1b4c175da34d6b0de3d8d69bee6bcc"}, - {file = "regex-2023.10.3-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:a8f4e49fc3ce020f65411432183e6775f24e02dff617281094ba6ab079ef0915"}, - {file = "regex-2023.10.3-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4cd1bccf99d3ef1ab6ba835308ad85be040e6a11b0977ef7ea8c8005f01a3c29"}, - {file = "regex-2023.10.3-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:81dce2ddc9f6e8f543d94b05d56e70d03a0774d32f6cca53e978dc01e4fc75b8"}, - {file = "regex-2023.10.3-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9c6b4d23c04831e3ab61717a707a5d763b300213db49ca680edf8bf13ab5d91b"}, - {file = "regex-2023.10.3-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c15ad0aee158a15e17e0495e1e18741573d04eb6da06d8b84af726cfc1ed02ee"}, - {file = "regex-2023.10.3-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:6239d4e2e0b52c8bd38c51b760cd870069f0bdf99700a62cd509d7a031749a55"}, - {file = "regex-2023.10.3-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:4a8bf76e3182797c6b1afa5b822d1d5802ff30284abe4599e1247be4fd6b03be"}, - {file = "regex-2023.10.3-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:d9c727bbcf0065cbb20f39d2b4f932f8fa1631c3e01fcedc979bd4f51fe051c5"}, - {file = "regex-2023.10.3-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:3ccf2716add72f80714b9a63899b67fa711b654be3fcdd34fa391d2d274ce767"}, - {file = "regex-2023.10.3-cp310-cp310-musllinux_1_1_ppc64le.whl", hash = "sha256:107ac60d1bfdc3edb53be75e2a52aff7481b92817cfdddd9b4519ccf0e54a6ff"}, - {file = "regex-2023.10.3-cp310-cp310-musllinux_1_1_s390x.whl", hash = "sha256:00ba3c9818e33f1fa974693fb55d24cdc8ebafcb2e4207680669d8f8d7cca79a"}, - {file = "regex-2023.10.3-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:f0a47efb1dbef13af9c9a54a94a0b814902e547b7f21acb29434504d18f36e3a"}, - {file = "regex-2023.10.3-cp310-cp310-win32.whl", hash = "sha256:36362386b813fa6c9146da6149a001b7bd063dabc4d49522a1f7aa65b725c7ec"}, - {file = "regex-2023.10.3-cp310-cp310-win_amd64.whl", hash = "sha256:c65a3b5330b54103e7d21cac3f6bf3900d46f6d50138d73343d9e5b2900b2353"}, - {file = "regex-2023.10.3-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:90a79bce019c442604662d17bf69df99090e24cdc6ad95b18b6725c2988a490e"}, - {file = "regex-2023.10.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:c7964c2183c3e6cce3f497e3a9f49d182e969f2dc3aeeadfa18945ff7bdd7051"}, - {file = "regex-2023.10.3-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4ef80829117a8061f974b2fda8ec799717242353bff55f8a29411794d635d964"}, - {file = "regex-2023.10.3-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5addc9d0209a9afca5fc070f93b726bf7003bd63a427f65ef797a931782e7edc"}, - {file = "regex-2023.10.3-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:c148bec483cc4b421562b4bcedb8e28a3b84fcc8f0aa4418e10898f3c2c0eb9b"}, - {file = "regex-2023.10.3-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8d1f21af4c1539051049796a0f50aa342f9a27cde57318f2fc41ed50b0dbc4ac"}, - {file = "regex-2023.10.3-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0b9ac09853b2a3e0d0082104036579809679e7715671cfbf89d83c1cb2a30f58"}, - {file = "regex-2023.10.3-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:ebedc192abbc7fd13c5ee800e83a6df252bec691eb2c4bedc9f8b2e2903f5e2a"}, - {file = "regex-2023.10.3-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:d8a993c0a0ffd5f2d3bda23d0cd75e7086736f8f8268de8a82fbc4bd0ac6791e"}, - {file = "regex-2023.10.3-cp311-cp311-musllinux_1_1_ppc64le.whl", hash = "sha256:be6b7b8d42d3090b6c80793524fa66c57ad7ee3fe9722b258aec6d0672543fd0"}, - {file = "regex-2023.10.3-cp311-cp311-musllinux_1_1_s390x.whl", hash = "sha256:4023e2efc35a30e66e938de5aef42b520c20e7eda7bb5fb12c35e5d09a4c43f6"}, - {file = "regex-2023.10.3-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:0d47840dc05e0ba04fe2e26f15126de7c755496d5a8aae4a08bda4dd8d646c54"}, - {file = "regex-2023.10.3-cp311-cp311-win32.whl", hash = "sha256:9145f092b5d1977ec8c0ab46e7b3381b2fd069957b9862a43bd383e5c01d18c2"}, - {file = "regex-2023.10.3-cp311-cp311-win_amd64.whl", hash = "sha256:b6104f9a46bd8743e4f738afef69b153c4b8b592d35ae46db07fc28ae3d5fb7c"}, - {file = "regex-2023.10.3-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:bff507ae210371d4b1fe316d03433ac099f184d570a1a611e541923f78f05037"}, - {file = "regex-2023.10.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:be5e22bbb67924dea15039c3282fa4cc6cdfbe0cbbd1c0515f9223186fc2ec5f"}, - {file = "regex-2023.10.3-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4a992f702c9be9c72fa46f01ca6e18d131906a7180950958f766c2aa294d4b41"}, - {file = "regex-2023.10.3-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:7434a61b158be563c1362d9071358f8ab91b8d928728cd2882af060481244c9e"}, - {file = "regex-2023.10.3-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:c2169b2dcabf4e608416f7f9468737583ce5f0a6e8677c4efbf795ce81109d7c"}, - {file = "regex-2023.10.3-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a9e908ef5889cda4de038892b9accc36d33d72fb3e12c747e2799a0e806ec841"}, - {file = "regex-2023.10.3-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:12bd4bc2c632742c7ce20db48e0d99afdc05e03f0b4c1af90542e05b809a03d9"}, - {file = "regex-2023.10.3-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:bc72c231f5449d86d6c7d9cc7cd819b6eb30134bb770b8cfdc0765e48ef9c420"}, - {file = "regex-2023.10.3-cp312-cp312-musllinux_1_1_i686.whl", hash = "sha256:bce8814b076f0ce5766dc87d5a056b0e9437b8e0cd351b9a6c4e1134a7dfbda9"}, - {file = "regex-2023.10.3-cp312-cp312-musllinux_1_1_ppc64le.whl", hash = "sha256:ba7cd6dc4d585ea544c1412019921570ebd8a597fabf475acc4528210d7c4a6f"}, - {file = "regex-2023.10.3-cp312-cp312-musllinux_1_1_s390x.whl", hash = "sha256:b0c7d2f698e83f15228ba41c135501cfe7d5740181d5903e250e47f617eb4292"}, - {file = "regex-2023.10.3-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:5a8f91c64f390ecee09ff793319f30a0f32492e99f5dc1c72bc361f23ccd0a9a"}, - {file = "regex-2023.10.3-cp312-cp312-win32.whl", hash = "sha256:ad08a69728ff3c79866d729b095872afe1e0557251da4abb2c5faff15a91d19a"}, - {file = "regex-2023.10.3-cp312-cp312-win_amd64.whl", hash = "sha256:39cdf8d141d6d44e8d5a12a8569d5a227f645c87df4f92179bd06e2e2705e76b"}, - {file = "regex-2023.10.3-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:4a3ee019a9befe84fa3e917a2dd378807e423d013377a884c1970a3c2792d293"}, - {file = "regex-2023.10.3-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:76066d7ff61ba6bf3cb5efe2428fc82aac91802844c022d849a1f0f53820502d"}, - {file = "regex-2023.10.3-cp37-cp37m-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:bfe50b61bab1b1ec260fa7cd91106fa9fece57e6beba05630afe27c71259c59b"}, - {file = "regex-2023.10.3-cp37-cp37m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9fd88f373cb71e6b59b7fa597e47e518282455c2734fd4306a05ca219a1991b0"}, - {file = "regex-2023.10.3-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b3ab05a182c7937fb374f7e946f04fb23a0c0699c0450e9fb02ef567412d2fa3"}, - {file = "regex-2023.10.3-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:dac37cf08fcf2094159922edc7a2784cfcc5c70f8354469f79ed085f0328ebdf"}, - {file = "regex-2023.10.3-cp37-cp37m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:e54ddd0bb8fb626aa1f9ba7b36629564544954fff9669b15da3610c22b9a0991"}, - {file = "regex-2023.10.3-cp37-cp37m-musllinux_1_1_aarch64.whl", hash = "sha256:3367007ad1951fde612bf65b0dffc8fd681a4ab98ac86957d16491400d661302"}, - {file = "regex-2023.10.3-cp37-cp37m-musllinux_1_1_i686.whl", hash = "sha256:16f8740eb6dbacc7113e3097b0a36065a02e37b47c936b551805d40340fb9971"}, - {file = "regex-2023.10.3-cp37-cp37m-musllinux_1_1_ppc64le.whl", hash = "sha256:f4f2ca6df64cbdd27f27b34f35adb640b5d2d77264228554e68deda54456eb11"}, - {file = "regex-2023.10.3-cp37-cp37m-musllinux_1_1_s390x.whl", hash = "sha256:39807cbcbe406efca2a233884e169d056c35aa7e9f343d4e78665246a332f597"}, - {file = "regex-2023.10.3-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:7eece6fbd3eae4a92d7c748ae825cbc1ee41a89bb1c3db05b5578ed3cfcfd7cb"}, - {file = "regex-2023.10.3-cp37-cp37m-win32.whl", hash = "sha256:ce615c92d90df8373d9e13acddd154152645c0dc060871abf6bd43809673d20a"}, - {file = "regex-2023.10.3-cp37-cp37m-win_amd64.whl", hash = "sha256:0f649fa32fe734c4abdfd4edbb8381c74abf5f34bc0b3271ce687b23729299ed"}, - {file = "regex-2023.10.3-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:9b98b7681a9437262947f41c7fac567c7e1f6eddd94b0483596d320092004533"}, - {file = "regex-2023.10.3-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:91dc1d531f80c862441d7b66c4505cd6ea9d312f01fb2f4654f40c6fdf5cc37a"}, - {file = "regex-2023.10.3-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:82fcc1f1cc3ff1ab8a57ba619b149b907072e750815c5ba63e7aa2e1163384a4"}, - {file = "regex-2023.10.3-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:7979b834ec7a33aafae34a90aad9f914c41fd6eaa8474e66953f3f6f7cbd4368"}, - {file = "regex-2023.10.3-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:ef71561f82a89af6cfcbee47f0fabfdb6e63788a9258e913955d89fdd96902ab"}, - {file = "regex-2023.10.3-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:dd829712de97753367153ed84f2de752b86cd1f7a88b55a3a775eb52eafe8a94"}, - {file = "regex-2023.10.3-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:00e871d83a45eee2f8688d7e6849609c2ca2a04a6d48fba3dff4deef35d14f07"}, - {file = "regex-2023.10.3-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:706e7b739fdd17cb89e1fbf712d9dc21311fc2333f6d435eac2d4ee81985098c"}, - {file = "regex-2023.10.3-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:cc3f1c053b73f20c7ad88b0d1d23be7e7b3901229ce89f5000a8399746a6e039"}, - {file = "regex-2023.10.3-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:6f85739e80d13644b981a88f529d79c5bdf646b460ba190bffcaf6d57b2a9863"}, - {file = "regex-2023.10.3-cp38-cp38-musllinux_1_1_ppc64le.whl", hash = "sha256:741ba2f511cc9626b7561a440f87d658aabb3d6b744a86a3c025f866b4d19e7f"}, - {file = "regex-2023.10.3-cp38-cp38-musllinux_1_1_s390x.whl", hash = "sha256:e77c90ab5997e85901da85131fd36acd0ed2221368199b65f0d11bca44549711"}, - {file = "regex-2023.10.3-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:979c24cbefaf2420c4e377ecd1f165ea08cc3d1fbb44bdc51bccbbf7c66a2cb4"}, - {file = "regex-2023.10.3-cp38-cp38-win32.whl", hash = "sha256:58837f9d221744d4c92d2cf7201c6acd19623b50c643b56992cbd2b745485d3d"}, - {file = "regex-2023.10.3-cp38-cp38-win_amd64.whl", hash = "sha256:c55853684fe08d4897c37dfc5faeff70607a5f1806c8be148f1695be4a63414b"}, - {file = "regex-2023.10.3-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:2c54e23836650bdf2c18222c87f6f840d4943944146ca479858404fedeb9f9af"}, - {file = "regex-2023.10.3-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:69c0771ca5653c7d4b65203cbfc5e66db9375f1078689459fe196fe08b7b4930"}, - {file = "regex-2023.10.3-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6ac965a998e1388e6ff2e9781f499ad1eaa41e962a40d11c7823c9952c77123e"}, - {file = "regex-2023.10.3-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1c0e8fae5b27caa34177bdfa5a960c46ff2f78ee2d45c6db15ae3f64ecadde14"}, - {file = "regex-2023.10.3-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:6c56c3d47da04f921b73ff9415fbaa939f684d47293f071aa9cbb13c94afc17d"}, - {file = "regex-2023.10.3-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7ef1e014eed78ab650bef9a6a9cbe50b052c0aebe553fb2881e0453717573f52"}, - {file = "regex-2023.10.3-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d29338556a59423d9ff7b6eb0cb89ead2b0875e08fe522f3e068b955c3e7b59b"}, - {file = "regex-2023.10.3-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:9c6d0ced3c06d0f183b73d3c5920727268d2201aa0fe6d55c60d68c792ff3588"}, - {file = "regex-2023.10.3-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:994645a46c6a740ee8ce8df7911d4aee458d9b1bc5639bc968226763d07f00fa"}, - {file = "regex-2023.10.3-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:66e2fe786ef28da2b28e222c89502b2af984858091675044d93cb50e6f46d7af"}, - {file = "regex-2023.10.3-cp39-cp39-musllinux_1_1_ppc64le.whl", hash = "sha256:11175910f62b2b8c055f2b089e0fedd694fe2be3941b3e2633653bc51064c528"}, - {file = "regex-2023.10.3-cp39-cp39-musllinux_1_1_s390x.whl", hash = "sha256:06e9abc0e4c9ab4779c74ad99c3fc10d3967d03114449acc2c2762ad4472b8ca"}, - {file = "regex-2023.10.3-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:fb02e4257376ae25c6dd95a5aec377f9b18c09be6ebdefa7ad209b9137b73d48"}, - {file = "regex-2023.10.3-cp39-cp39-win32.whl", hash = "sha256:3b2c3502603fab52d7619b882c25a6850b766ebd1b18de3df23b2f939360e1bd"}, - {file = "regex-2023.10.3-cp39-cp39-win_amd64.whl", hash = "sha256:adbccd17dcaff65704c856bd29951c58a1bd4b2b0f8ad6b826dbd543fe740988"}, - {file = "regex-2023.10.3.tar.gz", hash = "sha256:3fef4f844d2290ee0ba57addcec17eec9e3df73f10a2748485dfd6a3a188cc0f"}, + {file = "regex-2023.12.25-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:0694219a1d54336fd0445ea382d49d36882415c0134ee1e8332afd1529f0baa5"}, + {file = "regex-2023.12.25-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:b014333bd0217ad3d54c143de9d4b9a3ca1c5a29a6d0d554952ea071cff0f1f8"}, + {file = "regex-2023.12.25-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:d865984b3f71f6d0af64d0d88f5733521698f6c16f445bb09ce746c92c97c586"}, + {file = "regex-2023.12.25-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1e0eabac536b4cc7f57a5f3d095bfa557860ab912f25965e08fe1545e2ed8b4c"}, + {file = "regex-2023.12.25-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:c25a8ad70e716f96e13a637802813f65d8a6760ef48672aa3502f4c24ea8b400"}, + {file = "regex-2023.12.25-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a9b6d73353f777630626f403b0652055ebfe8ff142a44ec2cf18ae470395766e"}, + {file = "regex-2023.12.25-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a9cc99d6946d750eb75827cb53c4371b8b0fe89c733a94b1573c9dd16ea6c9e4"}, + {file = "regex-2023.12.25-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:88d1f7bef20c721359d8675f7d9f8e414ec5003d8f642fdfd8087777ff7f94b5"}, + {file = "regex-2023.12.25-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:cb3fe77aec8f1995611f966d0c656fdce398317f850d0e6e7aebdfe61f40e1cd"}, + {file = "regex-2023.12.25-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:7aa47c2e9ea33a4a2a05f40fcd3ea36d73853a2aae7b4feab6fc85f8bf2c9704"}, + {file = "regex-2023.12.25-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:df26481f0c7a3f8739fecb3e81bc9da3fcfae34d6c094563b9d4670b047312e1"}, + {file = "regex-2023.12.25-cp310-cp310-musllinux_1_1_ppc64le.whl", hash = "sha256:c40281f7d70baf6e0db0c2f7472b31609f5bc2748fe7275ea65a0b4601d9b392"}, + {file = "regex-2023.12.25-cp310-cp310-musllinux_1_1_s390x.whl", hash = "sha256:d94a1db462d5690ebf6ae86d11c5e420042b9898af5dcf278bd97d6bda065423"}, + {file = "regex-2023.12.25-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:ba1b30765a55acf15dce3f364e4928b80858fa8f979ad41f862358939bdd1f2f"}, + {file = "regex-2023.12.25-cp310-cp310-win32.whl", hash = "sha256:150c39f5b964e4d7dba46a7962a088fbc91f06e606f023ce57bb347a3b2d4630"}, + {file = "regex-2023.12.25-cp310-cp310-win_amd64.whl", hash = "sha256:09da66917262d9481c719599116c7dc0c321ffcec4b1f510c4f8a066f8768105"}, + {file = "regex-2023.12.25-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:1b9d811f72210fa9306aeb88385b8f8bcef0dfbf3873410413c00aa94c56c2b6"}, + {file = "regex-2023.12.25-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:d902a43085a308cef32c0d3aea962524b725403fd9373dea18110904003bac97"}, + {file = "regex-2023.12.25-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:d166eafc19f4718df38887b2bbe1467a4f74a9830e8605089ea7a30dd4da8887"}, + {file = "regex-2023.12.25-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c7ad32824b7f02bb3c9f80306d405a1d9b7bb89362d68b3c5a9be53836caebdb"}, + {file = "regex-2023.12.25-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:636ba0a77de609d6510235b7f0e77ec494d2657108f777e8765efc060094c98c"}, + {file = "regex-2023.12.25-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:0fda75704357805eb953a3ee15a2b240694a9a514548cd49b3c5124b4e2ad01b"}, + {file = "regex-2023.12.25-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f72cbae7f6b01591f90814250e636065850c5926751af02bb48da94dfced7baa"}, + {file = "regex-2023.12.25-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:db2a0b1857f18b11e3b0e54ddfefc96af46b0896fb678c85f63fb8c37518b3e7"}, + {file = "regex-2023.12.25-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:7502534e55c7c36c0978c91ba6f61703faf7ce733715ca48f499d3dbbd7657e0"}, + {file = "regex-2023.12.25-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:e8c7e08bb566de4faaf11984af13f6bcf6a08f327b13631d41d62592681d24fe"}, + {file = "regex-2023.12.25-cp311-cp311-musllinux_1_1_ppc64le.whl", hash = "sha256:283fc8eed679758de38fe493b7d7d84a198b558942b03f017b1f94dda8efae80"}, + {file = "regex-2023.12.25-cp311-cp311-musllinux_1_1_s390x.whl", hash = "sha256:f44dd4d68697559d007462b0a3a1d9acd61d97072b71f6d1968daef26bc744bd"}, + {file = "regex-2023.12.25-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:67d3ccfc590e5e7197750fcb3a2915b416a53e2de847a728cfa60141054123d4"}, + {file = "regex-2023.12.25-cp311-cp311-win32.whl", hash = "sha256:68191f80a9bad283432385961d9efe09d783bcd36ed35a60fb1ff3f1ec2efe87"}, + {file = "regex-2023.12.25-cp311-cp311-win_amd64.whl", hash = "sha256:7d2af3f6b8419661a0c421584cfe8aaec1c0e435ce7e47ee2a97e344b98f794f"}, + {file = "regex-2023.12.25-cp312-cp312-macosx_10_9_universal2.whl", hash = "sha256:8a0ccf52bb37d1a700375a6b395bff5dd15c50acb745f7db30415bae3c2b0715"}, + {file = "regex-2023.12.25-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:c3c4a78615b7762740531c27cf46e2f388d8d727d0c0c739e72048beb26c8a9d"}, + {file = "regex-2023.12.25-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ad83e7545b4ab69216cef4cc47e344d19622e28aabec61574b20257c65466d6a"}, + {file = "regex-2023.12.25-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b7a635871143661feccce3979e1727c4e094f2bdfd3ec4b90dfd4f16f571a87a"}, + {file = "regex-2023.12.25-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d498eea3f581fbe1b34b59c697512a8baef88212f92e4c7830fcc1499f5b45a5"}, + {file = "regex-2023.12.25-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:43f7cd5754d02a56ae4ebb91b33461dc67be8e3e0153f593c509e21d219c5060"}, + {file = "regex-2023.12.25-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:51f4b32f793812714fd5307222a7f77e739b9bc566dc94a18126aba3b92b98a3"}, + {file = "regex-2023.12.25-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ba99d8077424501b9616b43a2d208095746fb1284fc5ba490139651f971d39d9"}, + {file = "regex-2023.12.25-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:4bfc2b16e3ba8850e0e262467275dd4d62f0d045e0e9eda2bc65078c0110a11f"}, + {file = "regex-2023.12.25-cp312-cp312-musllinux_1_1_i686.whl", hash = "sha256:8c2c19dae8a3eb0ea45a8448356ed561be843b13cbc34b840922ddf565498c1c"}, + {file = "regex-2023.12.25-cp312-cp312-musllinux_1_1_ppc64le.whl", hash = "sha256:60080bb3d8617d96f0fb7e19796384cc2467447ef1c491694850ebd3670bc457"}, + {file = "regex-2023.12.25-cp312-cp312-musllinux_1_1_s390x.whl", hash = "sha256:b77e27b79448e34c2c51c09836033056a0547aa360c45eeeb67803da7b0eedaf"}, + {file = "regex-2023.12.25-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:518440c991f514331f4850a63560321f833979d145d7d81186dbe2f19e27ae3d"}, + {file = "regex-2023.12.25-cp312-cp312-win32.whl", hash = "sha256:e2610e9406d3b0073636a3a2e80db05a02f0c3169b5632022b4e81c0364bcda5"}, + {file = "regex-2023.12.25-cp312-cp312-win_amd64.whl", hash = "sha256:cc37b9aeebab425f11f27e5e9e6cf580be7206c6582a64467a14dda211abc232"}, + {file = "regex-2023.12.25-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:da695d75ac97cb1cd725adac136d25ca687da4536154cdc2815f576e4da11c69"}, + {file = "regex-2023.12.25-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d126361607b33c4eb7b36debc173bf25d7805847346dd4d99b5499e1fef52bc7"}, + {file = "regex-2023.12.25-cp37-cp37m-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:4719bb05094d7d8563a450cf8738d2e1061420f79cfcc1fa7f0a44744c4d8f73"}, + {file = "regex-2023.12.25-cp37-cp37m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:5dd58946bce44b53b06d94aa95560d0b243eb2fe64227cba50017a8d8b3cd3e2"}, + {file = "regex-2023.12.25-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:22a86d9fff2009302c440b9d799ef2fe322416d2d58fc124b926aa89365ec482"}, + {file = "regex-2023.12.25-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:2aae8101919e8aa05ecfe6322b278f41ce2994c4a430303c4cd163fef746e04f"}, + {file = "regex-2023.12.25-cp37-cp37m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:e692296c4cc2873967771345a876bcfc1c547e8dd695c6b89342488b0ea55cd8"}, + {file = "regex-2023.12.25-cp37-cp37m-musllinux_1_1_aarch64.whl", hash = "sha256:263ef5cc10979837f243950637fffb06e8daed7f1ac1e39d5910fd29929e489a"}, + {file = "regex-2023.12.25-cp37-cp37m-musllinux_1_1_i686.whl", hash = "sha256:d6f7e255e5fa94642a0724e35406e6cb7001c09d476ab5fce002f652b36d0c39"}, + {file = "regex-2023.12.25-cp37-cp37m-musllinux_1_1_ppc64le.whl", hash = "sha256:88ad44e220e22b63b0f8f81f007e8abbb92874d8ced66f32571ef8beb0643b2b"}, + {file = "regex-2023.12.25-cp37-cp37m-musllinux_1_1_s390x.whl", hash = "sha256:3a17d3ede18f9cedcbe23d2daa8a2cd6f59fe2bf082c567e43083bba3fb00347"}, + {file = "regex-2023.12.25-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:d15b274f9e15b1a0b7a45d2ac86d1f634d983ca40d6b886721626c47a400bf39"}, + {file = "regex-2023.12.25-cp37-cp37m-win32.whl", hash = "sha256:ed19b3a05ae0c97dd8f75a5d8f21f7723a8c33bbc555da6bbe1f96c470139d3c"}, + {file = "regex-2023.12.25-cp37-cp37m-win_amd64.whl", hash = "sha256:a6d1047952c0b8104a1d371f88f4ab62e6275567d4458c1e26e9627ad489b445"}, + {file = "regex-2023.12.25-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:b43523d7bc2abd757119dbfb38af91b5735eea45537ec6ec3a5ec3f9562a1c53"}, + {file = "regex-2023.12.25-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:efb2d82f33b2212898f1659fb1c2e9ac30493ac41e4d53123da374c3b5541e64"}, + {file = "regex-2023.12.25-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:b7fca9205b59c1a3d5031f7e64ed627a1074730a51c2a80e97653e3e9fa0d415"}, + {file = "regex-2023.12.25-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:086dd15e9435b393ae06f96ab69ab2d333f5d65cbe65ca5a3ef0ec9564dfe770"}, + {file = "regex-2023.12.25-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e81469f7d01efed9b53740aedd26085f20d49da65f9c1f41e822a33992cb1590"}, + {file = "regex-2023.12.25-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:34e4af5b27232f68042aa40a91c3b9bb4da0eeb31b7632e0091afc4310afe6cb"}, + {file = "regex-2023.12.25-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9852b76ab558e45b20bf1893b59af64a28bd3820b0c2efc80e0a70a4a3ea51c1"}, + {file = "regex-2023.12.25-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ff100b203092af77d1a5a7abe085b3506b7eaaf9abf65b73b7d6905b6cb76988"}, + {file = "regex-2023.12.25-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:cc038b2d8b1470364b1888a98fd22d616fba2b6309c5b5f181ad4483e0017861"}, + {file = "regex-2023.12.25-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:094ba386bb5c01e54e14434d4caabf6583334090865b23ef58e0424a6286d3dc"}, + {file = "regex-2023.12.25-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:5cd05d0f57846d8ba4b71d9c00f6f37d6b97d5e5ef8b3c3840426a475c8f70f4"}, + {file = "regex-2023.12.25-cp38-cp38-musllinux_1_1_ppc64le.whl", hash = "sha256:9aa1a67bbf0f957bbe096375887b2505f5d8ae16bf04488e8b0f334c36e31360"}, + {file = "regex-2023.12.25-cp38-cp38-musllinux_1_1_s390x.whl", hash = "sha256:98a2636994f943b871786c9e82bfe7883ecdaba2ef5df54e1450fa9869d1f756"}, + {file = "regex-2023.12.25-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:37f8e93a81fc5e5bd8db7e10e62dc64261bcd88f8d7e6640aaebe9bc180d9ce2"}, + {file = "regex-2023.12.25-cp38-cp38-win32.whl", hash = "sha256:d78bd484930c1da2b9679290a41cdb25cc127d783768a0369d6b449e72f88beb"}, + {file = "regex-2023.12.25-cp38-cp38-win_amd64.whl", hash = "sha256:b521dcecebc5b978b447f0f69b5b7f3840eac454862270406a39837ffae4e697"}, + {file = "regex-2023.12.25-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:f7bc09bc9c29ebead055bcba136a67378f03d66bf359e87d0f7c759d6d4ffa31"}, + {file = "regex-2023.12.25-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:e14b73607d6231f3cc4622809c196b540a6a44e903bcfad940779c80dffa7be7"}, + {file = "regex-2023.12.25-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:9eda5f7a50141291beda3edd00abc2d4a5b16c29c92daf8d5bd76934150f3edc"}, + {file = "regex-2023.12.25-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:cc6bb9aa69aacf0f6032c307da718f61a40cf970849e471254e0e91c56ffca95"}, + {file = "regex-2023.12.25-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:298dc6354d414bc921581be85695d18912bea163a8b23cac9a2562bbcd5088b1"}, + {file = "regex-2023.12.25-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2f4e475a80ecbd15896a976aa0b386c5525d0ed34d5c600b6d3ebac0a67c7ddf"}, + {file = "regex-2023.12.25-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:531ac6cf22b53e0696f8e1d56ce2396311254eb806111ddd3922c9d937151dae"}, + {file = "regex-2023.12.25-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:22f3470f7524b6da61e2020672df2f3063676aff444db1daa283c2ea4ed259d6"}, + {file = "regex-2023.12.25-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:89723d2112697feaa320c9d351e5f5e7b841e83f8b143dba8e2d2b5f04e10923"}, + {file = "regex-2023.12.25-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:0ecf44ddf9171cd7566ef1768047f6e66975788258b1c6c6ca78098b95cf9a3d"}, + {file = "regex-2023.12.25-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:905466ad1702ed4acfd67a902af50b8db1feeb9781436372261808df7a2a7bca"}, + {file = "regex-2023.12.25-cp39-cp39-musllinux_1_1_ppc64le.whl", hash = "sha256:4558410b7a5607a645e9804a3e9dd509af12fb72b9825b13791a37cd417d73a5"}, + {file = "regex-2023.12.25-cp39-cp39-musllinux_1_1_s390x.whl", hash = "sha256:7e316026cc1095f2a3e8cc012822c99f413b702eaa2ca5408a513609488cb62f"}, + {file = "regex-2023.12.25-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:3b1de218d5375cd6ac4b5493e0b9f3df2be331e86520f23382f216c137913d20"}, + {file = "regex-2023.12.25-cp39-cp39-win32.whl", hash = "sha256:11a963f8e25ab5c61348d090bf1b07f1953929c13bd2309a0662e9ff680763c9"}, + {file = "regex-2023.12.25-cp39-cp39-win_amd64.whl", hash = "sha256:e693e233ac92ba83a87024e1d32b5f9ab15ca55ddd916d878146f4e3406b5c91"}, + {file = "regex-2023.12.25.tar.gz", hash = "sha256:29171aa128da69afdf4bde412d5bedc335f2ca8fcfe4489038577d05f16181e5"}, ] [[package]] @@ -1455,18 +1449,18 @@ test = ["fixtures", "mock", "purl", "pytest", "requests-futures", "sphinx", "tes [[package]] name = "setuptools" -version = "69.0.2" +version = "69.1.0" description = "Easily download, build, install, upgrade, and uninstall Python packages" optional = false python-versions = ">=3.8" files = [ - {file = "setuptools-69.0.2-py3-none-any.whl", hash = "sha256:1e8fdff6797d3865f37397be788a4e3cba233608e9b509382a2777d25ebde7f2"}, - {file = "setuptools-69.0.2.tar.gz", hash = "sha256:735896e78a4742605974de002ac60562d286fa8051a7e2299445e8e8fbb01aa6"}, + {file = "setuptools-69.1.0-py3-none-any.whl", hash = "sha256:c054629b81b946d63a9c6e732bc8b2513a7c3ea645f11d0139a2191d735c60c6"}, + {file = "setuptools-69.1.0.tar.gz", hash = "sha256:850894c4195f09c4ed30dba56213bf7c3f21d86ed6bdaafb5df5972593bfc401"}, ] [package.extras] docs = ["furo", "jaraco.packaging (>=9.3)", "jaraco.tidelift (>=1.4)", "pygments-github-lexers (==0.0.5)", "rst.linker (>=1.9)", "sphinx (<7.2.5)", "sphinx (>=3.5)", "sphinx-favicon", "sphinx-inline-tabs", "sphinx-lint", "sphinx-notfound-page (>=1,<2)", "sphinx-reredirects", "sphinxcontrib-towncrier"] -testing = ["build[virtualenv]", "filelock (>=3.4.0)", "flake8-2020", "ini2toml[lite] (>=0.9)", "jaraco.develop (>=7.21)", "jaraco.envs (>=2.2)", "jaraco.path (>=3.2.0)", "pip (>=19.1)", "pytest (>=6)", "pytest-black (>=0.3.7)", "pytest-checkdocs (>=2.4)", "pytest-cov", "pytest-enabler (>=2.2)", "pytest-mypy (>=0.9.1)", "pytest-perf", "pytest-ruff", "pytest-timeout", "pytest-xdist", "tomli-w (>=1.0.0)", "virtualenv (>=13.0.0)", "wheel"] +testing = ["build[virtualenv]", "filelock (>=3.4.0)", "flake8-2020", "ini2toml[lite] (>=0.9)", "jaraco.develop (>=7.21)", "jaraco.envs (>=2.2)", "jaraco.path (>=3.2.0)", "pip (>=19.1)", "pytest (>=6)", "pytest-checkdocs (>=2.4)", "pytest-cov", "pytest-enabler (>=2.2)", "pytest-home (>=0.5)", "pytest-mypy (>=0.9.1)", "pytest-perf", "pytest-ruff (>=0.2.1)", "pytest-timeout", "pytest-xdist", "tomli-w (>=1.0.0)", "virtualenv (>=13.0.0)", "wheel"] testing-integration = ["build[virtualenv] (>=1.0.3)", "filelock (>=3.4.0)", "jaraco.envs (>=2.2)", "jaraco.path (>=3.2.0)", "packaging (>=23.1)", "pytest", "pytest-enabler", "pytest-xdist", "tomli", "virtualenv (>=13.0.0)", "wheel"] [[package]] @@ -1524,30 +1518,30 @@ files = [ [[package]] name = "tox" -version = "4.11.4" +version = "4.12.1" description = "tox is a generic virtualenv management and test command line tool" optional = false python-versions = ">=3.8" files = [ - {file = "tox-4.11.4-py3-none-any.whl", hash = "sha256:2adb83d68f27116812b69aa36676a8d6a52249cb0d173649de0e7d0c2e3e7229"}, - {file = "tox-4.11.4.tar.gz", hash = "sha256:73a7240778fabf305aeb05ab8ea26e575e042ab5a18d71d0ed13e343a51d6ce1"}, + {file = "tox-4.12.1-py3-none-any.whl", hash = "sha256:c07ea797880a44f3c4f200ad88ad92b446b83079d4ccef89585df64cc574375c"}, + {file = "tox-4.12.1.tar.gz", hash = "sha256:61aafbeff1bd8a5af84e54ef6e8402f53c6a6066d0782336171ddfbf5362122e"}, ] [package.dependencies] -cachetools = ">=5.3.1" +cachetools = ">=5.3.2" chardet = ">=5.2" colorama = ">=0.4.6" -filelock = ">=3.12.3" -packaging = ">=23.1" -platformdirs = ">=3.10" +filelock = ">=3.13.1" +packaging = ">=23.2" +platformdirs = ">=4.1" pluggy = ">=1.3" pyproject-api = ">=1.6.1" tomli = {version = ">=2.0.1", markers = "python_version < \"3.11\""} -virtualenv = ">=20.24.3" +virtualenv = ">=20.25" [package.extras] -docs = ["furo (>=2023.8.19)", "sphinx (>=7.2.4)", "sphinx-argparse-cli (>=1.11.1)", "sphinx-autodoc-typehints (>=1.24)", "sphinx-copybutton (>=0.5.2)", "sphinx-inline-tabs (>=2023.4.21)", "sphinxcontrib-towncrier (>=0.2.1a0)", "towncrier (>=23.6)"] -testing = ["build[virtualenv] (>=0.10)", "covdefaults (>=2.3)", "detect-test-pollution (>=1.1.1)", "devpi-process (>=1)", "diff-cover (>=7.7)", "distlib (>=0.3.7)", "flaky (>=3.7)", "hatch-vcs (>=0.3)", "hatchling (>=1.18)", "psutil (>=5.9.5)", "pytest (>=7.4)", "pytest-cov (>=4.1)", "pytest-mock (>=3.11.1)", "pytest-xdist (>=3.3.1)", "re-assert (>=1.1)", "time-machine (>=2.12)", "wheel (>=0.41.2)"] +docs = ["furo (>=2023.9.10)", "sphinx (>=7.2.6)", "sphinx-argparse-cli (>=1.11.1)", "sphinx-autodoc-typehints (>=1.25.2)", "sphinx-copybutton (>=0.5.2)", "sphinx-inline-tabs (>=2023.4.21)", "sphinxcontrib-towncrier (>=0.2.1a0)", "towncrier (>=23.11)"] +testing = ["build[virtualenv] (>=1.0.3)", "covdefaults (>=2.3)", "detect-test-pollution (>=1.2)", "devpi-process (>=1)", "diff-cover (>=8.0.2)", "distlib (>=0.3.8)", "flaky (>=3.7)", "hatch-vcs (>=0.4)", "hatchling (>=1.21)", "psutil (>=5.9.7)", "pytest (>=7.4.4)", "pytest-cov (>=4.1)", "pytest-mock (>=3.12)", "pytest-xdist (>=3.5)", "re-assert (>=1.1)", "time-machine (>=2.13)", "wheel (>=0.42)"] [[package]] name = "types-cryptography" @@ -1562,13 +1556,13 @@ files = [ [[package]] name = "types-requests" -version = "2.31.0.10" +version = "2.31.0.20240125" description = "Typing stubs for requests" optional = false -python-versions = ">=3.7" +python-versions = ">=3.8" files = [ - {file = "types-requests-2.31.0.10.tar.gz", hash = "sha256:dc5852a76f1eaf60eafa81a2e50aefa3d1f015c34cf0cba130930866b1b22a92"}, - {file = "types_requests-2.31.0.10-py3-none-any.whl", hash = "sha256:b32b9a86beffa876c0c3ac99a4cd3b8b51e973fb8e3bd4e0a6bb32c7efad80fc"}, + {file = "types-requests-2.31.0.20240125.tar.gz", hash = "sha256:03a28ce1d7cd54199148e043b2079cdded22d6795d19a2c2a6791a4b2b5e2eb5"}, + {file = "types_requests-2.31.0.20240125-py3-none-any.whl", hash = "sha256:9592a9a4cb92d6d75d9b491a41477272b710e021011a2a3061157e2fb1f1a5d1"}, ] [package.dependencies] @@ -1576,40 +1570,41 @@ urllib3 = ">=2" [[package]] name = "typing-extensions" -version = "4.8.0" +version = "4.9.0" description = "Backported and Experimental Type Hints for Python 3.8+" optional = false python-versions = ">=3.8" files = [ - {file = "typing_extensions-4.8.0-py3-none-any.whl", hash = "sha256:8f92fc8806f9a6b641eaa5318da32b44d401efaac0f6678c9bc448ba3605faa0"}, - {file = "typing_extensions-4.8.0.tar.gz", hash = "sha256:df8e4339e9cb77357558cbdbceca33c303714cf861d1eef15e1070055ae8b7ef"}, + {file = "typing_extensions-4.9.0-py3-none-any.whl", hash = "sha256:af72aea155e91adfc61c3ae9e0e342dbc0cba726d6cba4b6c72c1f34e47291cd"}, + {file = "typing_extensions-4.9.0.tar.gz", hash = "sha256:23478f88c37f27d76ac8aee6c905017a143b0b1b886c3c9f66bc2fd94f9f5783"}, ] [[package]] name = "urllib3" -version = "2.1.0" +version = "2.2.0" description = "HTTP library with thread-safe connection pooling, file post, and more." optional = false python-versions = ">=3.8" files = [ - {file = "urllib3-2.1.0-py3-none-any.whl", hash = "sha256:55901e917a5896a349ff771be919f8bd99aff50b79fe58fec595eb37bbc56bb3"}, - {file = "urllib3-2.1.0.tar.gz", hash = "sha256:df7aa8afb0148fa78488e7899b2c59b5f4ffcfa82e6c54ccb9dd37c1d7b52d54"}, + {file = "urllib3-2.2.0-py3-none-any.whl", hash = "sha256:ce3711610ddce217e6d113a2732fafad960a03fd0318c91faa79481e35c11224"}, + {file = "urllib3-2.2.0.tar.gz", hash = "sha256:051d961ad0c62a94e50ecf1af379c3aba230c66c710493493560c0c223c49f20"}, ] [package.extras] brotli = ["brotli (>=1.0.9)", "brotlicffi (>=0.8.0)"] +h2 = ["h2 (>=4,<5)"] socks = ["pysocks (>=1.5.6,!=1.5.7,<2.0)"] zstd = ["zstandard (>=0.18.0)"] [[package]] name = "virtualenv" -version = "20.24.7" +version = "20.25.0" description = "Virtual Python Environment builder" optional = false python-versions = ">=3.7" files = [ - {file = "virtualenv-20.24.7-py3-none-any.whl", hash = "sha256:a18b3fd0314ca59a2e9f4b556819ed07183b3e9a3702ecfe213f593d44f7b3fd"}, - {file = "virtualenv-20.24.7.tar.gz", hash = "sha256:69050ffb42419c91f6c1284a7b24e0475d793447e35929b488bf6a0aade39353"}, + {file = "virtualenv-20.25.0-py3-none-any.whl", hash = "sha256:4238949c5ffe6876362d9c0180fc6c3a824a7b12b80604eeb8085f2ed7460de3"}, + {file = "virtualenv-20.25.0.tar.gz", hash = "sha256:bf51c0d9c7dd63ea8e44086fa1e4fb1093a31e963b86959257378aef020e1f1b"}, ] [package.dependencies] @@ -1623,43 +1618,59 @@ test = ["covdefaults (>=2.3)", "coverage (>=7.2.7)", "coverage-enable-subprocess [[package]] name = "watchdog" -version = "3.0.0" +version = "4.0.0" description = "Filesystem events monitoring" optional = false -python-versions = ">=3.7" +python-versions = ">=3.8" files = [ - {file = "watchdog-3.0.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:336adfc6f5cc4e037d52db31194f7581ff744b67382eb6021c868322e32eef41"}, - {file = "watchdog-3.0.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:a70a8dcde91be523c35b2bf96196edc5730edb347e374c7de7cd20c43ed95397"}, - {file = "watchdog-3.0.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:adfdeab2da79ea2f76f87eb42a3ab1966a5313e5a69a0213a3cc06ef692b0e96"}, - {file = "watchdog-3.0.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:2b57a1e730af3156d13b7fdddfc23dea6487fceca29fc75c5a868beed29177ae"}, - {file = "watchdog-3.0.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:7ade88d0d778b1b222adebcc0927428f883db07017618a5e684fd03b83342bd9"}, - {file = "watchdog-3.0.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:7e447d172af52ad204d19982739aa2346245cc5ba6f579d16dac4bfec226d2e7"}, - {file = "watchdog-3.0.0-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:9fac43a7466eb73e64a9940ac9ed6369baa39b3bf221ae23493a9ec4d0022674"}, - {file = "watchdog-3.0.0-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:8ae9cda41fa114e28faf86cb137d751a17ffd0316d1c34ccf2235e8a84365c7f"}, - {file = "watchdog-3.0.0-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:25f70b4aa53bd743729c7475d7ec41093a580528b100e9a8c5b5efe8899592fc"}, - {file = "watchdog-3.0.0-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:4f94069eb16657d2c6faada4624c39464f65c05606af50bb7902e036e3219be3"}, - {file = "watchdog-3.0.0-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:7c5f84b5194c24dd573fa6472685b2a27cc5a17fe5f7b6fd40345378ca6812e3"}, - {file = "watchdog-3.0.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:3aa7f6a12e831ddfe78cdd4f8996af9cf334fd6346531b16cec61c3b3c0d8da0"}, - {file = "watchdog-3.0.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:233b5817932685d39a7896b1090353fc8efc1ef99c9c054e46c8002561252fb8"}, - {file = "watchdog-3.0.0-pp37-pypy37_pp73-macosx_10_9_x86_64.whl", hash = "sha256:13bbbb462ee42ec3c5723e1205be8ced776f05b100e4737518c67c8325cf6100"}, - {file = "watchdog-3.0.0-pp38-pypy38_pp73-macosx_10_9_x86_64.whl", hash = "sha256:8f3ceecd20d71067c7fd4c9e832d4e22584318983cabc013dbf3f70ea95de346"}, - {file = "watchdog-3.0.0-pp39-pypy39_pp73-macosx_10_9_x86_64.whl", hash = "sha256:c9d8c8ec7efb887333cf71e328e39cffbf771d8f8f95d308ea4125bf5f90ba64"}, - {file = "watchdog-3.0.0-py3-none-manylinux2014_aarch64.whl", hash = "sha256:0e06ab8858a76e1219e68c7573dfeba9dd1c0219476c5a44d5333b01d7e1743a"}, - {file = "watchdog-3.0.0-py3-none-manylinux2014_armv7l.whl", hash = "sha256:d00e6be486affb5781468457b21a6cbe848c33ef43f9ea4a73b4882e5f188a44"}, - {file = "watchdog-3.0.0-py3-none-manylinux2014_i686.whl", hash = "sha256:c07253088265c363d1ddf4b3cdb808d59a0468ecd017770ed716991620b8f77a"}, - {file = "watchdog-3.0.0-py3-none-manylinux2014_ppc64.whl", hash = "sha256:5113334cf8cf0ac8cd45e1f8309a603291b614191c9add34d33075727a967709"}, - {file = "watchdog-3.0.0-py3-none-manylinux2014_ppc64le.whl", hash = "sha256:51f90f73b4697bac9c9a78394c3acbbd331ccd3655c11be1a15ae6fe289a8c83"}, - {file = "watchdog-3.0.0-py3-none-manylinux2014_s390x.whl", hash = "sha256:ba07e92756c97e3aca0912b5cbc4e5ad802f4557212788e72a72a47ff376950d"}, - {file = "watchdog-3.0.0-py3-none-manylinux2014_x86_64.whl", hash = "sha256:d429c2430c93b7903914e4db9a966c7f2b068dd2ebdd2fa9b9ce094c7d459f33"}, - {file = "watchdog-3.0.0-py3-none-win32.whl", hash = "sha256:3ed7c71a9dccfe838c2f0b6314ed0d9b22e77d268c67e015450a29036a81f60f"}, - {file = "watchdog-3.0.0-py3-none-win_amd64.whl", hash = "sha256:4c9956d27be0bb08fc5f30d9d0179a855436e655f046d288e2bcc11adfae893c"}, - {file = "watchdog-3.0.0-py3-none-win_ia64.whl", hash = "sha256:5d9f3a10e02d7371cd929b5d8f11e87d4bad890212ed3901f9b4d68767bee759"}, - {file = "watchdog-3.0.0.tar.gz", hash = "sha256:4d98a320595da7a7c5a18fc48cb633c2e73cda78f93cac2ef42d42bf609a33f9"}, + {file = "watchdog-4.0.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:39cb34b1f1afbf23e9562501673e7146777efe95da24fab5707b88f7fb11649b"}, + {file = "watchdog-4.0.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:c522392acc5e962bcac3b22b9592493ffd06d1fc5d755954e6be9f4990de932b"}, + {file = "watchdog-4.0.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:6c47bdd680009b11c9ac382163e05ca43baf4127954c5f6d0250e7d772d2b80c"}, + {file = "watchdog-4.0.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:8350d4055505412a426b6ad8c521bc7d367d1637a762c70fdd93a3a0d595990b"}, + {file = "watchdog-4.0.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:c17d98799f32e3f55f181f19dd2021d762eb38fdd381b4a748b9f5a36738e935"}, + {file = "watchdog-4.0.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4986db5e8880b0e6b7cd52ba36255d4793bf5cdc95bd6264806c233173b1ec0b"}, + {file = "watchdog-4.0.0-cp312-cp312-macosx_10_9_universal2.whl", hash = "sha256:11e12fafb13372e18ca1bbf12d50f593e7280646687463dd47730fd4f4d5d257"}, + {file = "watchdog-4.0.0-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:5369136a6474678e02426bd984466343924d1df8e2fd94a9b443cb7e3aa20d19"}, + {file = "watchdog-4.0.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:76ad8484379695f3fe46228962017a7e1337e9acadafed67eb20aabb175df98b"}, + {file = "watchdog-4.0.0-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:45cc09cc4c3b43fb10b59ef4d07318d9a3ecdbff03abd2e36e77b6dd9f9a5c85"}, + {file = "watchdog-4.0.0-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:eed82cdf79cd7f0232e2fdc1ad05b06a5e102a43e331f7d041e5f0e0a34a51c4"}, + {file = "watchdog-4.0.0-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:ba30a896166f0fee83183cec913298151b73164160d965af2e93a20bbd2ab605"}, + {file = "watchdog-4.0.0-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:d18d7f18a47de6863cd480734613502904611730f8def45fc52a5d97503e5101"}, + {file = "watchdog-4.0.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:2895bf0518361a9728773083908801a376743bcc37dfa252b801af8fd281b1ca"}, + {file = "watchdog-4.0.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:87e9df830022488e235dd601478c15ad73a0389628588ba0b028cb74eb72fed8"}, + {file = "watchdog-4.0.0-pp310-pypy310_pp73-macosx_10_9_x86_64.whl", hash = "sha256:6e949a8a94186bced05b6508faa61b7adacc911115664ccb1923b9ad1f1ccf7b"}, + {file = "watchdog-4.0.0-pp38-pypy38_pp73-macosx_10_9_x86_64.whl", hash = "sha256:6a4db54edea37d1058b08947c789a2354ee02972ed5d1e0dca9b0b820f4c7f92"}, + {file = "watchdog-4.0.0-pp39-pypy39_pp73-macosx_10_9_x86_64.whl", hash = "sha256:d31481ccf4694a8416b681544c23bd271f5a123162ab603c7d7d2dd7dd901a07"}, + {file = "watchdog-4.0.0-py3-none-manylinux2014_aarch64.whl", hash = "sha256:8fec441f5adcf81dd240a5fe78e3d83767999771630b5ddfc5867827a34fa3d3"}, + {file = "watchdog-4.0.0-py3-none-manylinux2014_armv7l.whl", hash = "sha256:6a9c71a0b02985b4b0b6d14b875a6c86ddea2fdbebd0c9a720a806a8bbffc69f"}, + {file = "watchdog-4.0.0-py3-none-manylinux2014_i686.whl", hash = "sha256:557ba04c816d23ce98a06e70af6abaa0485f6d94994ec78a42b05d1c03dcbd50"}, + {file = "watchdog-4.0.0-py3-none-manylinux2014_ppc64.whl", hash = "sha256:d0f9bd1fd919134d459d8abf954f63886745f4660ef66480b9d753a7c9d40927"}, + {file = "watchdog-4.0.0-py3-none-manylinux2014_ppc64le.whl", hash = "sha256:f9b2fdca47dc855516b2d66eef3c39f2672cbf7e7a42e7e67ad2cbfcd6ba107d"}, + {file = "watchdog-4.0.0-py3-none-manylinux2014_s390x.whl", hash = "sha256:73c7a935e62033bd5e8f0da33a4dcb763da2361921a69a5a95aaf6c93aa03a87"}, + {file = "watchdog-4.0.0-py3-none-manylinux2014_x86_64.whl", hash = "sha256:6a80d5cae8c265842c7419c560b9961561556c4361b297b4c431903f8c33b269"}, + {file = "watchdog-4.0.0-py3-none-win32.whl", hash = "sha256:8f9a542c979df62098ae9c58b19e03ad3df1c9d8c6895d96c0d51da17b243b1c"}, + {file = "watchdog-4.0.0-py3-none-win_amd64.whl", hash = "sha256:f970663fa4f7e80401a7b0cbeec00fa801bf0287d93d48368fc3e6fa32716245"}, + {file = "watchdog-4.0.0-py3-none-win_ia64.whl", hash = "sha256:9a03e16e55465177d416699331b0f3564138f1807ecc5f2de9d55d8f188d08c7"}, + {file = "watchdog-4.0.0.tar.gz", hash = "sha256:e3e7065cbdabe6183ab82199d7a4f6b3ba0a438c5a512a68559846ccb76a78ec"}, ] [package.extras] watchmedo = ["PyYAML (>=3.10)"] +[[package]] +name = "wcmatch" +version = "8.5" +description = "Wildcard/glob file name matcher." +optional = false +python-versions = ">=3.8" +files = [ + {file = "wcmatch-8.5-py3-none-any.whl", hash = "sha256:14554e409b142edeefab901dc68ad570b30a72a8ab9a79106c5d5e9a6d241bd5"}, + {file = "wcmatch-8.5.tar.gz", hash = "sha256:86c17572d0f75cbf3bcb1a18f3bf2f9e72b39a9c08c9b4a74e991e1882a8efb3"}, +] + +[package.dependencies] +bracex = ">=2.1.1" + [[package]] name = "werkzeug" version = "3.0.1" @@ -1699,4 +1710,4 @@ test = [] [metadata] lock-version = "2.0" python-versions = ">=3.8" -content-hash = "eca30792e61e874c2339aefacb3f4a623dce7e430d8e11079184b16471cda4fe" +content-hash = "922d87346e4c3c8f08c58c36273399943ecd2339bbc83f384a6dbea0cb7db25a" diff --git a/pyproject.toml b/pyproject.toml index 0216c34..656a0fa 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,7 +1,7 @@ [tool] [tool.poetry] name = "requests_oauth2client" -version = "1.3.0" +version = "1.5.0" homepage = "https://github.com/guillp/requests_oauth2client" description = "An OAuth2.x Client based on requests." authors = ["Guillaume Pujol "] @@ -12,7 +12,6 @@ classifiers = [ 'Intended Audience :: Developers', 'Topic :: Security', 'License :: OSI Approved :: Apache Software License', - 'Natural Language :: English', 'Programming Language :: Python :: 3', 'Programming Language :: Python :: 3.8', 'Programming Language :: Python :: 3.9', @@ -29,24 +28,23 @@ packages = [ python = ">=3.8" requests = ">=2.19.0" -binapy = ">=0.7" +binapy = ">=0.8" furl = ">=2.1.2" -jwskate = ">=0.9" +jwskate = ">=0.11.1" [tool.poetry.dev-dependencies] -black = ">=22.10.0" -coverage = ">=6.3.1" -isort = ">=5.9.3" +coverage = ">=7.4" flask = ">=2.0.1" livereload = ">=2.6.3" -mypy = ">=1.4" +mypy = ">=1.8" mkdocs = ">=1.3.1" mkdocs-autorefs = ">=0.3.0" -mkdocs-material = ">=8.2.1" +mkdocs-include-markdown-plugin = ">=6" +mkdocs-material = ">=9.5.3" mkdocs-material-extensions = ">=1.0.1" mkdocstrings = { version = ">=0.18.0", extras = ["python"] } -pre-commit = ">=2.12.0" +pre-commit = ">=3.5.0" pytest = ">=7.0.1" pytest-cov = ">=3.0.0" pytest-freezer = ">=0.4.8" @@ -67,23 +65,74 @@ doc = ["mdformat", "mkdocs", "mkdocs-autorefs", "mkdocs-include-markdown-plugin" requires = ["poetry-core>=1.0.0"] build-backend = "poetry.core.masonry.api" -[tool.black] -line-length = 96 -include = '\.pyi?$' -exclude = ''' -/( - \.eggs - | \.git - | \.hg - | \.mypy_cache - | \.tox - | \.venv - | _build - | buck-out - | build - | dist -)/ -''' +[tool.docformatter] +black = true +recursive = true +wrap-summaries = 100 +wrap-descriptions = 100 +blank = true + +[tool.ruff] +target-version = "py38" +line-length = 120 +extend-exclude = [ + "tests" +] + +[tool.ruff.format] +docstring-code-format = true +line-ending = "lf" + +[tool.ruff.lint] +select = [ + "A", + "B", + "C", + "C4", + "D", + "DTZ", + "E", + "EM", + "ERA", + "F", + "FA", + "FBT", + "I", + "ICN", + "ISC", + "N", + "PGH", + "PLC", + "PLE", + "PLR", + "PLW", + "PTH", + "Q", + "RUF", + "S", + "SIM", + "T", + "TID", + "TRY", + "UP", + "W", + "YTT", +] +ignore = [ + "RUF012", # Mutable class attributes should be annotated with `typing.ClassVar` + "N818", # Exception names should be named with an Error suffix + "PLR0912", # Too many branches + "D107", # Missing docstring in `__init__` + "S105", # Possible hardcoded password + "ISC001", # single-line-implicit-string-concatenation +] + +[tool.ruff.lint.pylint] +max-args = 10 + +[tool.ruff.lint.pydocstyle] +convention = "google" +ignore-decorators = ['override'] [tool.mypy] strict = true diff --git a/requests_oauth2client/__init__.py b/requests_oauth2client/__init__.py index 5192a5f..5af0821 100644 --- a/requests_oauth2client/__init__.py +++ b/requests_oauth2client/__init__.py @@ -1,12 +1,15 @@ """Main module for `requests_oauth2client`. You can import any class from any submodule directly from this main module. + """ import requests +from jwskate import EncryptionAlgs, KeyManagementAlgs, SignatureAlgs from .api_client import ApiClient from .auth import ( + BaseOAuth2RenewableTokenAuth, BearerAuth, OAuth2AccessTokenAuth, OAuth2AuthorizationCodeAuth, @@ -19,15 +22,20 @@ AuthorizationRequestSerializer, AuthorizationResponse, PkceUtils, + RequestParameterAuthorizationRequest, RequestUriParameterAuthorizationRequest, ) from .backchannel_authentication import ( BackChannelAuthenticationPoolingJob, BackChannelAuthenticationResponse, ) -from .client import OAuth2Client +from .client import ( + GrantType, + OAuth2Client, +) from .client_authentication import ( BaseClientAuthenticationMethod, + ClientAssertionAuthenticationMethod, ClientSecretBasic, ClientSecretJwt, ClientSecretPost, @@ -48,12 +56,16 @@ AccountSelectionRequired, AuthorizationPending, AuthorizationResponseError, + BackChannelAuthenticationError, ConsentRequired, DeviceAuthorizationError, EndpointError, ExpiredAccessToken, + ExpiredIdToken, ExpiredToken, InteractionRequired, + IntrospectionError, + InvalidAuthResponse, InvalidBackChannelAuthenticationResponse, InvalidClient, InvalidDeviceAuthorizationResponse, @@ -62,19 +74,121 @@ InvalidPushedAuthorizationResponse, InvalidRequest, InvalidScope, + InvalidTarget, InvalidTokenResponse, LoginRequired, + MismatchingAcr, + MismatchingAudience, + MismatchingAzp, + MismatchingIdTokenAlg, MismatchingIssuer, + MismatchingNonce, MismatchingState, MissingAuthCode, + MissingIdToken, MissingIssuer, OAuth2Error, + RevocationError, ServerError, SessionSelectionRequired, SlowDown, + TokenEndpointError, UnauthorizedClient, UnknownIntrospectionError, UnknownTokenEndpointError, UnsupportedTokenType, ) -from .tokens import BearerToken, BearerTokenSerializer, IdToken +from .pooling import ( + TokenEndpointPoolingJob, +) +from .tokens import ( + BearerToken, + BearerTokenSerializer, + IdToken, +) + +__all__ = [ + "AccessDenied", + "AccountSelectionRequired", + "ApiClient", + "AuthorizationPending", + "AuthorizationRequest", + "AuthorizationRequestSerializer", + "AuthorizationResponse", + "AuthorizationResponseError", + "BackChannelAuthenticationError", + "BackChannelAuthenticationPoolingJob", + "BackChannelAuthenticationResponse", + "BaseClientAuthenticationMethod", + "BaseOAuth2RenewableTokenAuth", + "BearerAuth", + "BearerToken", + "BearerTokenSerializer", + "ClientAssertionAuthenticationMethod", + "ClientSecretBasic", + "ClientSecretJwt", + "ClientSecretPost", + "ConsentRequired", + "DeviceAuthorizationError", + "DeviceAuthorizationPoolingJob", + "DeviceAuthorizationResponse", + "EncryptionAlgs", + "EndpointError", + "ExpiredAccessToken", + "ExpiredIdToken", + "ExpiredToken", + "GrantType", + "IdToken", + "InteractionRequired", + "IntrospectionError", + "InvalidAuthResponse", + "InvalidBackChannelAuthenticationResponse", + "InvalidClient", + "InvalidDeviceAuthorizationResponse", + "InvalidGrant", + "InvalidIdToken", + "InvalidPushedAuthorizationResponse", + "InvalidRequest", + "InvalidScope", + "InvalidTarget", + "InvalidTokenResponse", + "KeyManagementAlgs", + "LoginRequired", + "MismatchingAcr", + "MismatchingAudience", + "MismatchingAzp", + "MismatchingIdTokenAlg", + "MismatchingIssuer", + "MismatchingNonce", + "MismatchingState", + "MissingAuthCode", + "MissingIdToken", + "MissingIssuer", + "OAuth2AccessTokenAuth", + "OAuth2AuthorizationCodeAuth", + "OAuth2Client", + "OAuth2ClientCredentialsAuth", + "OAuth2DeviceCodeAuth", + "OAuth2Error", + "OAuth2ResourceOwnerPasswordAuth", + "PkceUtils", + "PrivateKeyJwt", + "PublicApp", + "RequestParameterAuthorizationRequest", + "RequestUriParameterAuthorizationRequest", + "RevocationError", + "ServerError", + "SessionSelectionRequired", + "SignatureAlgs", + "SlowDown", + "TokenEndpointError", + "TokenEndpointPoolingJob", + "UnauthorizedClient", + "UnknownIntrospectionError", + "UnknownTokenEndpointError", + "UnsupportedTokenType", + "requests", + "oauth2_discovery_document_url", + "oidc_discovery_document_url", + "well_known_uri", +] diff --git a/requests_oauth2client/api_client.py b/requests_oauth2client/api_client.py index e2df261..f865a92 100644 --- a/requests_oauth2client/api_client.py +++ b/requests_oauth2client/api_client.py @@ -1,4 +1,4 @@ -"""ApiClient main module.""" +"""`ApiClient` main module.""" from __future__ import annotations @@ -7,25 +7,34 @@ from urllib.parse import urljoin import requests +from attrs import field, frozen from requests.cookies import RequestsCookieJar from typing_extensions import Literal +@frozen(init=False) class ApiClient: - """A Wrapper around [requests.Session][] with extra features for Rest API calls. + """A Wrapper around [requests.Session][] with extra features for REST API calls. - Additional features compared to [requests.Session][]: + Additional features compared to using a [requests.Session][] directly: - - Allows setting a root url at creation time, then passing relative urls at request time. + - You must set a root url at creation time, which then allows passing relative urls at request time. - It may also raise exceptions instead of returning error responses. - - You can also pass additional kwargs at init time, which will be used to configure the [Session][requests.Session], - instead of setting them later. - - for parameters passed as `json`, `params` or `data`, values that are `None` can be automatically discarded from the request - - boolean values in `data` or `params` fields can be serialized to values that are suitable for the target API, like `"true"` or `"false"`, or `"1"` / `"0"`, instead of the default values `"True"` or `"False"`. - - `base_url` will serve as root for relative urls passed to [ApiClient.request()][requests_oauth2client.api_client.ApiClient.request], [ApiClient.get()][requests_oauth2client.api_client.ApiClient.get], etc. - An `HTTPError` will be raised everytime an API call returns an error code (>= 400), unless you set `raise_for_status` to `False`. - Additional parameters passed at init time, including `auth` will be used to configure the [Session][requests.Session]. + - You can also pass additional kwargs at init time, which will be used to configure the + [Session][requests.Session], instead of setting them later. + - for parameters passed as `json`, `params` or `data`, values that are `None` can be + automatically discarded from the request + - boolean values in `data` or `params` fields can be serialized to values that are suitable + for the target API, like `"true"` or `"false"`, or `"1"` / `"0"`, instead of the default + values `"True"` or `"False"`. + + `base_url` will serve as root for relative urls passed to + [ApiClient.request()][requests_oauth2client.api_client.ApiClient.request], + [ApiClient.get()][requests_oauth2client.api_client.ApiClient.get], etc. + + An `HTTPError` will be raised everytime an API call returns an error code (>= 400), unless + you set `raise_for_status` to `False`. Additional parameters passed at init time, including + `auth` will be used to configure the [Session][requests.Session]. Usage: ```python @@ -40,51 +49,79 @@ class ApiClient: session.proxies = {"https": "https://localhost:3128"} api = ApiClient("https://myapi.local/resource", session=session) - # or you can let ApiClient init it's own session and provide additional configuration parameters: + # or you can let ApiClient init its own session and provide additional configuration + # parameters: api = ApiClient( - "https://myapi.local/resource", proxies={"https": "https://localhost:3128"} + "https://myapi.local/resource", + proxies={"https": "https://localhost:3128"}, ) ``` Args: - base_url: the base api url, that should be root for all the target API endpoints. + base_url: the base api url, that is the root for all the target API endpoints. auth: the [requests.auth.AuthBase][] to use as authentication handler. - timeout: the default timeout, in seconds, to use for each request from this ApiClient. Can be set to `None` to disable timeout. - raise_for_status: if `True`, exceptions will be raised everytime a request returns an error code (>= 400). - none_fields: if `"exclude"` (default), `data` or `json` fields whose values are `None` are not included in the request. If `"include"`, they are included with string value `None` (this is the default behavior of `requests`). If "empty", they are included with an empty value (as an empty string). - bool_fields: a tuple of (true_value, false_value). Fields from `data` or `params` with a boolean value (`True` or `False`) will be serialized to the corresponding value. This can be useful since some APIs expect a `'true'` or `'false'` value as boolean, and requests serialises `True` to `'True'` and `False` to `'False'`. Set it to `None` to restore default requests behaviour. - **kwargs: additional kwargs to configure this session. This parameter may be overridden at request time. + timeout: the default timeout, in seconds, to use for each request from this `ApiClient`. + Can be set to `None` to disable timeout. + raise_for_status: if `True`, exceptions will be raised everytime a request returns an + error code (>= 400). + none_fields: what to do with parameters with value `None` in `data` or `json` fields. + + - if `"exclude"` (default), fields whose values are `None` are not included in the request. + - if `"include"`, they are included with string value `None`. Note that this is + the default behavior of `requests`. + - if "empty", they are included with an empty value (as an empty string). + bool_fields: a tuple of (true_value, false_value). Fields from `data` or `params` with + a boolean value (`True` or `False`) will be serialized to the corresponding value. + This can be useful since some APIs expect a `'true'` or `'false'` value as boolean, + and `requests` serializes `True` to `'True'` and `False` to `'False'`. + Set it to `None` to restore default requests behaviour. + session: a preconfigured `requests.Session` to use with this `ApiClient`. + **session_kwargs: additional kwargs to configure the underlying `requests.Session`. + """ + base_url: str + auth: requests.auth.AuthBase | None = None + timeout: int | None = 60 + raise_for_status: bool = True + none_fields: Literal["include", "exclude", "empty"] = "exclude" + bool_fields: tuple[Any, Any] | None = "true", "false" + session: requests.Session = field(factory=requests.Session) + def __init__( self, - base_url: str | None = None, + base_url: str, + *, auth: requests.auth.AuthBase | None = None, timeout: int | None = 60, raise_for_status: bool = True, none_fields: Literal["include", "exclude", "empty"] = "exclude", bool_fields: tuple[Any, Any] | None = ("true", "false"), session: requests.Session | None = None, - **kwargs: Any, + **session_kwargs: Any, ): - super().__init__() + session = session or requests.Session() + for key, val in session_kwargs.items(): + setattr(session, key, val) - self.base_url = base_url - self.raise_for_status = raise_for_status - self.none_fields = none_fields - self.bool_fields = bool_fields if bool_fields is not None else (True, False) - self.timeout = timeout - - self.session = session or requests.Session() - self.auth = auth - - for key, val in kwargs.items(): - setattr(self.session, key, val) + if bool_fields is None: + bool_fields = (True, False) + + self.__attrs_init__( + base_url=base_url, + auth=auth, + raise_for_status=raise_for_status, + none_fields=none_fields, + bool_fields=bool_fields, + timeout=timeout, + session=session, + ) - def request( # noqa: C901 + def request( # noqa: C901, PLR0913, D417 self, method: str, url: None | str | bytes | Iterable[str | bytes | int] = None, + *, params: None | bytes | MutableMapping[str, str] = None, data: ( Iterable[bytes] @@ -111,10 +148,7 @@ def request( # noqa: C901 | ( MutableMapping[ str, - ( - Iterable[Callable[[requests.Response], Any]] - | Callable[[requests.Response], Any] - ), + (Iterable[Callable[[requests.Response], Any]] | Callable[[requests.Response], Any]), ] ) = None, stream: bool | None = None, @@ -128,22 +162,31 @@ def request( # noqa: C901 """Overridden `request` method with extra features. Features added compared to plain request(): - - it can handle a relative path instead of a full url, which will be appended to the base_url + + - takes a relative path instead of a full url, which will be appended to the + base_url - it can raise an exception when the API returns a non-success status code - - allow_redirects is False by default (API usually don't use redirects) - - `data` or `json` fields with value `None` can either be included or excluded from the request - - boolean fields can be serialized to `'true'` or `'false'` instead of `'True'` and `'False'` + - allow_redirects is False by default (since API usually don't use redirects) + - `data` or `json` fields with value `None` can either be included or excluded from the + request + - boolean fields can be serialized to `'true'` or `'false'` instead of `'True'` and + `'False'` Args: method: the HTTP method to use - url: the url where the request will be sent to. Can be a path instead of a full url; that path will be - joined to the configured API url. Can also be an iterable of path segments, that will be joined to the root url. - raise_for_status: like the parameter of the same name from `ApiClient.__init__`, but this will be applied for this request only. - none_fields: like the parameter of the same name from `ApiClient.__init__`, but this will be applied for this request only. - bool_fields: like the parameter of the same name from `ApiClient.__init__`, but this will be applied for this request only. + url: the url where the request will be sent to. Can be a path, as str ; + that path will be joined to the configured API url. Can also be an iterable of path + segments, that will be joined to the root url. + raise_for_status: like the parameter of the same name from `ApiClient.__init__`, + but this will be applied for this request only. + none_fields: like the parameter of the same name from `ApiClient.__init__`, + but this will be applied for this request only. + bool_fields: like the parameter of the same name from `ApiClient.__init__`, + but this will be applied for this request only. Returns: a [requests.Response][] as returned by requests + """ url = self.to_absolute_url(url) @@ -168,9 +211,8 @@ def request( # noqa: C901 try: true_value, false_value = bool_fields except ValueError: - raise ValueError( - "Invalid value for 'bool_fields'. Must be a 2 value tuple, with (true_value, false_value)." - ) + msg = "Invalid value for 'bool_fields'. Must be a 2 value tuple, with (true_value, false_value)." + raise ValueError(msg) from None if isinstance(data, MutableMapping): for key, val in data.items(): if val is True: @@ -211,34 +253,35 @@ def request( # noqa: C901 response.raise_for_status() return response - def to_absolute_url( - self, relative_url: None | str | bytes | Iterable[str | bytes | int] = None - ) -> str: + def to_absolute_url(self, relative_url: None | str | bytes | Iterable[str | bytes | int] = None) -> str: """Convert a relative url to an absolute url. - Given a `relative_url`, return the matching absolute url, based on the `base_url` that is configured for this API. + Given a `relative_url`, return the matching absolute url, based on the `base_url` that is + configured for this API. - The result of this methods is different from a standard `urljoin()`, because a relative_url that starts with a "/" - will not override the path from the base url. - You can also pass an iterable of path parts as relative url, which will be properly joined with "/". Those parts may be - `str` (which will be urlencoded) or `bytes` (which will be decoded as UTF-8 first) or any other type (which will be converted to `str` first, using the `str() function`). - See the table below for examples results which would exhibit most cases: + The result of this method is different from a standard `urljoin()`, because a relative_url + that starts with a "/" will not override the path from the base url. You can also pass an + iterable of path parts as relative url, which will be properly joined with "/". Those parts + may be `str` (which will be urlencoded) or `bytes` (which will be decoded as UTF-8 first) or + any other type (which will be converted to `str` first, using the `str() function`). See the + table below for example results which would exhibit most cases: - | base_url | relative_url | result_url | + | base_url | relative_url | result_url | |---------------------------|-----------------------------|-------------------------------------------| - | "https://myhost.com/root" | "/path" | "https://myhost.com/root/path" | - | "https://myhost.com/root" | "/path" | "https://myhost.com/root/path" | - | "https://myhost.com/root" | b"/path" | "https://myhost.com/root/path" | - | "https://myhost.com/root" | "path" | "https://myhost.com/root/path" | - | "https://myhost.com/root" | None | "https://myhost.com/root" | - | "https://myhost.com/root" | ("user", 1, "resource") | "https://myhost.com/root/user/1/resource" | - | "https://myhost.com/root" | "https://otherhost.org/foo" | "https://otherhost.org/foo" | + | "https://myhost.com/root" | "/path" | "https://myhost.com/root/path" | + | "https://myhost.com/root" | "/path" | "https://myhost.com/root/path" | + | "https://myhost.com/root" | b"/path" | "https://myhost.com/root/path" | + | "https://myhost.com/root" | "path" | "https://myhost.com/root/path" | + | "https://myhost.com/root" | None | "https://myhost.com/root" | + | "https://myhost.com/root" | ("user", 1, "resource") | "https://myhost.com/root/user/1/resource" | + | "https://myhost.com/root" | "https://otherhost.org/foo" | ValueError | Args: relative_url: a relative url Returns: the resulting absolute url + """ url = relative_url @@ -247,30 +290,32 @@ def to_absolute_url( if not isinstance(url, (str, bytes)): try: url = "/".join( - [ - urlencode( - part.decode() if isinstance(part, bytes) else str(part) - ) - for part in url - if part - ] + [urlencode(part.decode() if isinstance(part, bytes) else str(part)) for part in url if part] + ) + except Exception as exc: + msg = ( + "Unexpected url type, please pass a relative path as string or" + " bytes, or an iterable of string-able objects" ) - except TypeError: raise TypeError( - "Unexpected url type, please pass a relative path as string or bytes, " - "or an iterable of string-able objects", + msg, type(url), - ) + ) from exc if isinstance(url, bytes): url = url.decode() + if "://" in url: + msg = "url must be relative to root_url" + raise ValueError(msg) + url = urljoin(self.base_url + "/", url.lstrip("/")) else: url = self.base_url if url is None or not isinstance(url, str): - raise ValueError("Unable to determine an absolute url.") + msg = "Unable to determine an absolute url." + raise ValueError(msg) return url @@ -282,8 +327,8 @@ def get( ) -> requests.Response: """Send a GET request. Return a [Response][requests.Response] object. - The passed `url` may be relative to the url passed at initialization time. - It takes the same parameters as [ApiClient.request()][requests_oauth2client.api_client.ApiClient.request]. + The passed `url` may be relative to the url passed at initialization time. It takes the same + parameters as [ApiClient.request()][requests_oauth2client.api_client.ApiClient.request]. Args: url: a url where the request will be sent. @@ -294,8 +339,8 @@ def get( a [Response][requests.Response] object. Raises: - requests.HTTPError: if `raises_for_status` is True (in this request or at initialization time) and an error response is returned. - and an error response is returned. + requests.HTTPError: if `raises_for_status` is `True` and an error response is returned. + """ return self.request("GET", url, raise_for_status=raise_for_status, **kwargs) @@ -307,11 +352,11 @@ def post( ) -> requests.Response: """Send a POST request. Return a [Response][requests.Response] object. - The passed `url` may be relative to the url passed at initialization time. - It takes the same parameters as [ApiClient.request()][requests_oauth2client.api_client.ApiClient.request]. + The passed `url` may be relative to the url passed at initialization time. It takes the same + parameters as [ApiClient.request()][requests_oauth2client.api_client.ApiClient.request]. Args: - url: a url where the request will be sent. + url: an url where the request will be sent. raise_for_status: overrides the `raises_for_status` parameter passed at initialization time. **kwargs: Optional arguments that ``request`` takes. @@ -319,7 +364,8 @@ def post( a [Response][requests.Response] object. Raises: - requests.HTTPError: if `raises_for_status` is True (in this request or at initialization time) and an error response is returned. + requests.HTTPError: if `raises_for_status` is `True` and an error response is returned. + """ return self.request("POST", url, raise_for_status=raise_for_status, **kwargs) @@ -331,11 +377,11 @@ def patch( ) -> requests.Response: """Send a PATCH request. Return a [Response][requests.Response] object. - The passed `url` may be relative to the url passed at initialization time. - It takes the same parameters as [ApiClient.request()][requests_oauth2client.api_client.ApiClient.request]. + The passed `url` may be relative to the url passed at initialization time. It takes the same + parameters as [ApiClient.request()][requests_oauth2client.api_client.ApiClient.request]. Args: - url: a url where the request will be sent. + url: an url where the request will be sent. raise_for_status: overrides the `raises_for_status` parameter passed at initialization time. **kwargs: Optional arguments that ``request`` takes. @@ -343,7 +389,8 @@ def patch( a [Response][requests.Response] object. Raises: - requests.HTTPError: if `raises_for_status` is True (in this request or at initialization time) and an error response is returned. + requests.HTTPError: if `raises_for_status` is `True` and an error response is returned. + """ return self.request("PATCH", url, raise_for_status=raise_for_status, **kwargs) @@ -355,8 +402,8 @@ def put( ) -> requests.Response: """Send a PUT request. Return a [Response][requests.Response] object. - The passed `url` may be relative to the url passed at initialization time. - It takes the same parameters as [ApiClient.request()][requests_oauth2client.api_client.ApiClient.request]. + The passed `url` may be relative to the url passed at initialization time. It takes the same + parameters as [ApiClient.request()][requests_oauth2client.api_client.ApiClient.request]. Args: url: a url where the request will be sent. @@ -367,7 +414,8 @@ def put( a [Response][requests.Response] object. Raises: - requests.HTTPError: if `raises_for_status` is True (in this request or at initialization time) and an error response is returned. + requests.HTTPError: if `raises_for_status` is `True` and an error response is returned. + """ return self.request("PUT", url, raise_for_status=raise_for_status, **kwargs) @@ -379,8 +427,8 @@ def delete( ) -> requests.Response: """Send a DELETE request. Return a [Response][requests.Response] object. - The passed `url` may be relative to the url passed at initialization time. - It takes the same parameters as [ApiClient.request()][requests_oauth2client.api_client.ApiClient.request]. + The passed `url` may be relative to the url passed at initialization time. It takes the same + parameters as [ApiClient.request()][requests_oauth2client.api_client.ApiClient.request]. Args: url: a url where the request will be sent. @@ -391,7 +439,8 @@ def delete( a [Response][requests.Response] object. Raises: - requests.HTTPError: if `raises_for_status` is True (in this request or at initialization time) and an error response is returned. + requests.HTTPError: if `raises_for_status` is `True` and an error response is returned. + """ return self.request("DELETE", url, raise_for_status=raise_for_status, **kwargs) @@ -406,10 +455,13 @@ def __getattr__(self, item: str) -> ApiClient: Usage: ```python + from requests_oauth2client import ApiClient + api = ApiClient("https://myapi.local") resource1 = api.resource1.get() # GET https://myapi.local/resource1 resource2 = api.resource2.get() # GET https://myapi.local/resource2 ``` + """ return self[item] @@ -424,10 +476,13 @@ def __getitem__(self, item: str) -> ApiClient: Usage: ```python + from requests_oauth2client import ApiClient + api = ApiClient("https://myapi.local") resource1 = api["resource1"].get() # GET https://myapi.local/resource1 resource2 = api["resource2"].get() # GET https://myapi.local/resource2 ``` + """ new_base_uri = self.to_absolute_url(item) return ApiClient( @@ -442,14 +497,15 @@ def __getitem__(self, item: str) -> ApiClient: def __enter__(self) -> ApiClient: """Allow `ApiClient` to act as a context manager. - You can then use an `ApiClient` instance in a `with` clause, the same way as `requests.Session`. - The underlying request.Session will be closed on exit. + You can then use an `ApiClient` instance in a `with` clause, the same way as + `requests.Session`. The underlying request.Session will be closed on exit. Usage: ```python with ApiClient("https://myapi.com/path") as client: resp = client.get("resource") ``` + """ return self diff --git a/requests_oauth2client/auth.py b/requests_oauth2client/auth.py index 2901ac8..7ec3343 100644 --- a/requests_oauth2client/auth.py +++ b/requests_oauth2client/auth.py @@ -1,4 +1,5 @@ -"""This module contains requests-compatible Auth Handlers that implement OAuth 2.0.""" +"""This module contains `requests`-compatible Auth Handlers that implement OAuth 2.0.""" + from __future__ import annotations from typing import TYPE_CHECKING, Any @@ -11,7 +12,7 @@ from .exceptions import ExpiredAccessToken from .tokens import BearerToken -if TYPE_CHECKING: # pragma: no cover +if TYPE_CHECKING: from .client import OAuth2Client @@ -21,7 +22,8 @@ class BearerAuth(requests.auth.AuthBase): As a prerequisite to using this `AuthBase`, you have to obtain an access token manually. You most likely don't want to do that by yourself, but instead use an instance of [OAuth2Client][requests_oauth2client.client.OAuth2Client] to do that for you. - See the others Auth Handlers in this module, which will automatically obtain access tokens from an OAuth 2.x server. + See the others Auth Handlers in this module, which will automatically obtain + access tokens from an OAuth 2.x server. [RFC6750$2.1]: https://datatracker.ietf.org/doc/html/rfc6750#section-2.1 @@ -39,7 +41,9 @@ class BearerAuth(requests.auth.AuthBase): ``` Args: - token: a [BearerToken][requests_oauth2client.tokens.BearerToken] or a string to use as token for this Auth Handler. If `None`, this Auth Handler is a no op. + token: a [BearerToken][requests_oauth2client.tokens.BearerToken] or a string + to use as token for this Auth Handler. If `None`, this Auth Handler is a no-op. + """ def __init__(self, token: str | BearerToken | None = None) -> None: @@ -50,7 +54,9 @@ def token(self) -> BearerToken | None: """Return the [BearerToken] that is used for authorization against the API. Returns: - the configured [BearerToken][requests_oauth2client.tokens.BearerToken] used with this AuthHandler. + the configured [BearerToken][requests_oauth2client.tokens.BearerToken] used with this + AuthHandler. + """ return self._token @@ -58,10 +64,12 @@ def token(self) -> BearerToken | None: def token(self, token: str | BearerToken | None) -> None: """Change the access token used with this AuthHandler. - Accepts a [BearerToken][requests_oauth2client.tokens.BearerToken] or an access token as `str`. + Accepts a [BearerToken][requests_oauth2client.tokens.BearerToken] or an access token as + `str`. Args: token: an access token to use for this Auth Handler + """ if token is not None and not isinstance(token, BearerToken): token = BearerToken(token) @@ -70,17 +78,19 @@ def token(self, token: str | BearerToken | None) -> None: def __call__(self, request: requests.PreparedRequest) -> requests.PreparedRequest: """Implement the usage of Bearer Tokens in requests. - This will add a properly formatted `Authorization: Bearer ` header in - the request. + This will add a properly formatted `Authorization: Bearer ` header in the request. - If the configured token is a instance of BearerToken with an expires_at attribute, - raises [ExpiredAccessToken][requests_oauth2client.exceptions.ExpiredAccessToken] once the access token is expired. + If the configured token is an instance of BearerToken with an expires_at attribute, raises + [ExpiredAccessToken][requests_oauth2client.exceptions.ExpiredAccessToken] once the access + token is expired. Args: request: a [PreparedRequest][requests.PreparedRequest] Returns: - a [PreparedRequest][requests.PreparedRequest] with an Access Token added in Authorization Header + a [PreparedRequest][requests.PreparedRequest] with an Access Token added in + Authorization Header + """ if self.token is None: return request @@ -91,19 +101,20 @@ def __call__(self, request: requests.PreparedRequest) -> requests.PreparedReques class BaseOAuth2RenewableTokenAuth(BearerAuth): - """Base class for Bearer Token based Auth Handlers, with on obtainable or renewable token. + """Base class for BearerToken-based Auth Handlers, with an obtainable or renewable token. In addition to adding a properly formatted `Authorization` header, this will obtain a new token - once the current token is expired. - Expiration is detected based on the `expires_in` hint returned by the AS. - A configurable `leeway`, in number of seconds, will make sure that a new token is obtained some seconds before the - actual expiration is reached. This may help in situations where the client, AS and RS have slightly offset clocks. + once the current token is expired. Expiration is detected based on the `expires_in` hint + returned by the AS. A configurable `leeway`, in number of seconds, will make sure that a new + token is obtained some seconds before the actual expiration is reached. This may help in + situations where the client, AS and RS have slightly offset clocks. Args: client: an OAuth2Client token: an initial Access Token, if you have one already. In most cases, leave `None`. leeway: expiration leeway, in number of seconds token_kwargs: additional kwargs to include in token requests + """ def __init__( @@ -119,9 +130,7 @@ def __init__( self.token_kwargs = token_kwargs @override - def __call__( - self, request: requests.PreparedRequest - ) -> requests.PreparedRequest: # noqa: D102 + def __call__(self, request: requests.PreparedRequest) -> requests.PreparedRequest: token = self.token if token is None or token.is_expired(self.leeway): self.renew_token() @@ -130,21 +139,22 @@ def __call__( def renew_token(self) -> None: """Obtain a new Bearer Token. - This should be implemented by subclasses. + Subclasses should implement this. + """ raise NotImplementedError def forget_token(self) -> None: - """Forget the current token, forcing a renewal on the next usage of this Auth Handler.""" + """Forget the current token, forcing a renewal on the next HTTP request.""" self.token = None class OAuth2ClientCredentialsAuth(BaseOAuth2RenewableTokenAuth): """An Auth Handler for the Client Credentials grant. - This [requests AuthBase][requests.auth.AuthBase] automatically gets Access - Tokens from an OAuth 2.0 Token Endpoint with the Client Credentials grant, and will - get a new one once the current one is expired. + This [requests AuthBase][requests.auth.AuthBase] automatically gets Access Tokens from an OAuth + 2.0 Token Endpoint with the Client Credentials grant, and will get a new one once the current + one is expired. Args: client: the [OAuth2Client][requests_oauth2client.client.OAuth2Client] to use to obtain Access Tokens. @@ -152,12 +162,11 @@ class OAuth2ClientCredentialsAuth(BaseOAuth2RenewableTokenAuth): Usage: ```python - client = OAuth2Client( - token_endpoint="https://my.as.local/token", auth=("client_id", "client_secret") - ) + client = OAuth2Client(token_endpoint="https://my.as.local/token", auth=("client_id", "client_secret")) oauth2cc = OAuth2ClientCredentialsAuth(client, scope="my_scope") resp = requests.post("https://my.api.local/resource", auth=oauth2cc) ``` + """ @override @@ -169,12 +178,13 @@ def renew_token(self) -> None: class OAuth2AccessTokenAuth(BaseOAuth2RenewableTokenAuth): """Authentication Handler for OAuth 2.0 Access Tokens and (optional) Refresh Tokens. - This [Requests Auth handler][requests.auth.AuthBase] implementation uses an access token as Bearer token, and can - automatically refresh it when expired, if a refresh token is available. + This [Requests Auth handler][requests.auth.AuthBase] implementation uses an access token as + Bearer token, and can automatically refresh it when expired, if a refresh token is available. - Token can be a simple `str` containing a raw access token value, or a [BearerToken][requests_oauth2client.tokens.BearerToken] - that can contain a refresh_token. If a refresh_token and an expiration date are available, this Auth Handler - will automatically refresh the access token once it is expired. + Token can be a simple `str` containing a raw access token value, or a + [BearerToken][requests_oauth2client.tokens.BearerToken] that can contain a refresh_token. If a + refresh_token and an expiration date are available, this Auth Handler will automatically refresh + the access token once it is expired. Args: client: the [OAuth2Client][requests_oauth2client.client.OAuth2Client] to use to refresh tokens. @@ -185,21 +195,19 @@ class OAuth2AccessTokenAuth(BaseOAuth2RenewableTokenAuth): ```python client = OAuth2Client(token_endpoint="https://my.as.local/token", auth=("client_id", "client_secret")) token = BearerToken( - access_token="access_token", - expires_in=600, - refresh_token="refresh_token") # obtain a BearerToken any way you see fit, including a refresh token + access_token="access_token", expires_in=600, refresh_token="refresh_token" + ) # obtain a BearerToken any way you see fit, including a refresh token oauth2at_auth = OAuth2ClientCredentialsAuth(client, token, scope="my_scope") resp = requests.post("https://my.api.local/resource", auth=oauth2at_auth) ```` + """ @override def renew_token(self) -> None: """Obtain a new token, using the Refresh Token, if available.""" if self.token and self.token.refresh_token and self.client is not None: - self.token = self.client.refresh_token( - refresh_token=self.token.refresh_token, **self.token_kwargs - ) + self.token = self.client.refresh_token(refresh_token=self.token.refresh_token, **self.token_kwargs) class OAuth2AuthorizationCodeAuth(OAuth2AccessTokenAuth): @@ -216,9 +224,10 @@ class OAuth2AuthorizationCodeAuth(OAuth2AccessTokenAuth): Usage: ```python client = OAuth2Client(token_endpoint="https://my.as.local/token", auth=("client_id", "client_secret")) - code = "my_code" # you must obtain this code yourself + code = "my_code" # you must obtain this code yourself resp = requests.post("https://my.api.local/resource", auth=OAuth2AuthorizationCodeAuth(client, code)) ```` + """ def __init__( @@ -241,7 +250,9 @@ def __call__(self, request: requests.PreparedRequest) -> requests.PreparedReques request: a [PreparedRequest][requests.PreparedRequest] Returns: - a [PreparedRequest][requests.PreparedRequest] with an Access Token added in Authorization Header + a [PreparedRequest][requests.PreparedRequest] with an Access Token added in + Authorization Header + """ token = self.token if token is None or token.is_expired(): @@ -258,22 +269,25 @@ def exchange_code_for_token(self) -> None: class OAuth2ResourceOwnerPasswordAuth(BaseOAuth2RenewableTokenAuth): """Authentication Handler for the [Resource Owner Password Flow](https://www.rfc-editor.org/rfc/rfc6749#section-4.3). - This [Requests Auth handler][requests.auth.AuthBase] implementation exchanges the user credentials for - an Access Token, then automatically obtains a new one once it is expired. + This [Requests Auth handler][requests.auth.AuthBase] implementation exchanges the user + credentials for an Access Token, then automatically obtains a new one once it is expired. - Note that this flow is considered *deprecated*, and the Authorization Code flow should be used whenever possible. - Among other bad things, ROPC does not support SSO nor MFA and depends on the user typing its credentials directly - inside the application instead of on a dedicated login page, which makes it totally insecure for 3rd party apps. + Note that this flow is considered *deprecated*, and the Authorization Code flow should be + used whenever possible. Among other bad things, ROPC does not support SSO nor MFA and + depends on the user typing its credentials directly inside the application instead of on a + dedicated login page, which makes it totally insecure for 3rd party apps. - It needs the username and password and an [OAuth2Client][requests_oauth2client.client.OAuth2Client] to be able to get - a token from the AS Token Endpoint just before the first request using this Auth Handler is being sent. + It needs the username and password and an + [OAuth2Client][requests_oauth2client.client.OAuth2Client] to be able to get a token from + the AS Token Endpoint just before the first request using this Auth Handler is being sent. Args: - client: the [OAuth2Client][requests_oauth2client.client.OAuth2Client] to use to obtain Access Tokens. - username: the username. - password: the user password. - leeway: an amount of time, in seconds. - **token_kwargs: additional kwargs to pass to the token endpoint. + client: the [OAuth2Client][requests_oauth2client.client.OAuth2Client] to use to obtain + Access Tokens + username: the username + password: the user password + leeway: an amount of time, in seconds + **token_kwargs: additional kwargs to pass to the token endpoint """ @@ -305,8 +319,9 @@ class OAuth2DeviceCodeAuth(OAuth2AccessTokenAuth): This [Requests Auth handler][requests.auth.AuthBase] implementation exchanges a Device Code for an Access Token, then automatically refreshes it once it is expired. - It needs a Device Code and an [OAuth2Client][requests_oauth2client.client.OAuth2Client] to be able to get - a token from the AS Token Endpoint just before the first request using this Auth Handler is being sent. + It needs a Device Code and an [OAuth2Client][requests_oauth2client.client.OAuth2Client] to be + able to get a token from the AS Token Endpoint just before the first request using this Auth + Handler is being sent. Args: client: the [OAuth2Client][requests_oauth2client.client.OAuth2Client] to use to obtain Access Tokens. @@ -349,6 +364,7 @@ def __call__(self, request: requests.PreparedRequest) -> requests.PreparedReques Returns: a [requests.PreparedRequest][] with an Access Token added in Authorization Header + """ token = self.token if token is None or token.is_expired(): @@ -359,6 +375,7 @@ def exchange_device_code_for_token(self) -> None: """Exchange the Device Code for an access token. This will poll the Token Endpoint until the user finishes the authorization process. + """ from .device_authorization import DeviceAuthorizationPoolingJob diff --git a/requests_oauth2client/authorization_request.py b/requests_oauth2client/authorization_request.py index 533b592..660b8de 100644 --- a/requests_oauth2client/authorization_request.py +++ b/requests_oauth2client/authorization_request.py @@ -1,15 +1,16 @@ """Classes and utilities related to Authorization Requests and Responses.""" + from __future__ import annotations import re import secrets from datetime import datetime -from typing import Any, Callable, Iterable, Mapping +from typing import Any, Callable, ClassVar, Iterable, Sequence +from attrs import Factory, asdict, field, fields, frozen from binapy import BinaPy -from furl import furl # type: ignore[import-not-found] +from furl import furl # type: ignore[import-untyped] from jwskate import JweCompact, Jwk, Jwt, SignedJwt -from typing_extensions import Literal from .exceptions import ( AuthorizationResponseError, @@ -29,6 +30,7 @@ class PkceUtils: """Contains helper methods for PKCE, as described in RFC7636. See [RFC7636](https://tools.ietf.org/html/rfc7636). + """ code_verifier_re = re.compile(r"^[a-zA-Z0-9_\-~.]{43,128}$") @@ -39,7 +41,8 @@ def generate_code_verifier(cls) -> str: """Generate a valid `code_verifier`. Returns: - a code_verifier ready to use for PKCE + a `code_verifier` ready to use for PKCE + """ return secrets.token_urlsafe(96) @@ -52,14 +55,16 @@ def derive_challenge(cls, verifier: str | bytes, method: str = "S256") -> str: method: the method to use for deriving the challenge. Accepts 'S256' or 'plain'. Returns: - a code_challenge derived from the given verifier + a `code_challenge` derived from the given verifier + """ if isinstance(verifier, bytes): verifier = verifier.decode() if not cls.code_verifier_re.match(verifier): + msg = f"Invalid code verifier, does not match {cls.code_verifier_re}" raise ValueError( - f"Invalid code verifier, does not match {cls.code_verifier_re}", + msg, verifier, ) @@ -68,7 +73,8 @@ def derive_challenge(cls, verifier: str | bytes, method: str = "S256") -> str: elif method == "plain": return verifier else: - raise ValueError("Unsupported code_challenge_method", method) + msg = "Unsupported code_challenge_method" + raise ValueError(msg, method) @classmethod def generate_code_verifier_and_challenge(cls, method: str = "S256") -> tuple[str, str]: @@ -78,16 +84,15 @@ def generate_code_verifier_and_challenge(cls, method: str = "S256") -> tuple[str method: the method to use for deriving the challenge. Accepts 'S256' or 'plain'. Returns: - a (code_verifier, code_challenge) tuple. + a `(code_verifier, code_challenge)` tuple. + """ verifier = cls.generate_code_verifier() challenge = cls.derive_challenge(verifier, method) return verifier, challenge @classmethod - def validate_code_verifier( - cls, verifier: str, challenge: str, method: str = "S256" - ) -> bool: + def validate_code_verifier(cls, verifier: str, challenge: str, method: str = "S256") -> bool: """Validate a `code_verifier` against a `code_challenge`. Args: @@ -97,72 +102,80 @@ def validate_code_verifier( Returns: `True` if verifier is valid, or `False` otherwise + """ - return ( - cls.code_verifier_re.match(verifier) is not None - and cls.derive_challenge(verifier, method) == challenge - ) + return cls.code_verifier_re.match(verifier) is not None and cls.derive_challenge(verifier, method) == challenge +@frozen(init=False) class AuthorizationResponse: """Represent a successful Authorization Response. - An Authorization Response is the redirection initiated by the AS - to the client's redirection endpoint (redirect_uri) after an Authorization Request. - This Response is typically created with a call to `AuthorizationRequest.validate_callback()` once the call - to the client Redirection Endpoint is made. - AuthorizationResponse contains the following, all accessible as attributes: - - all the parameters that have been returned by the AS, most notably the `code`, and optional parameters such as `state`. + An Authorization Response is the redirection initiated by the AS to the client's redirection + endpoint (redirect_uri) after an Authorization Request. This Response is typically created with + a call to `AuthorizationRequest.validate_callback()` once the call to the client Redirection + Endpoint is made. AuthorizationResponse contains the following, all accessible as attributes: + + - all the parameters that have been returned by the AS, most notably the `code`, and optional + parameters such as `state`. - the redirect_uri that was used for the Authorization Request - the code_verifier matching the code_challenge that was used for the Authorization Request - Parameters `redirect_uri` and `code_verifier` must be those from the matching `AuthorizationRequest`. - All other parameters including `code` and `state` must be those extracted from the Authorization Response parameters. + Parameters `redirect_uri` and `code_verifier` must be those from the matching + `AuthorizationRequest`. All other parameters including `code` and `state` must be those + extracted from the Authorization Response parameters. Args: code: the authorization code returned by the AS redirect_uri: the redirect_uri that was passed as parameter in the AuthorizationRequest - code_verifier: the code_verifier matching the code_challenge that was passed as parameter in the AuthorizationRequest + code_verifier: the code_verifier matching the code_challenge that was passed as + parameter in the AuthorizationRequest state: the state returned by the AS **kwargs: other parameters as returned by the AS - Usage: - ```python - request = AuthorizationRequest( - client_id, scope="openid", redirect_uri="http://localhost:54121/callback" - ) - webbrowser.open(request) # open the authorization request in a browser - response_uri = ... # at this point, manage to get the response uri - response = request.validate_callback( - response_uri - ) # get an AuthorizationResponse at this point - - client = OAuth2Client(token_endpoint, auth=(client_id, client_secret)) - client.authorization_code( - response - ) # you can pass this response on a call to `OAuth2Client.authorization_code()` - ``` """ + code: str + redirect_uri: str | None = None + code_verifier: str | None = None + state: str | None = None + nonce: str | None = None + acr_values: tuple[str, ...] | None = None + max_age: int | None = None + issuer: str | None = None + kwargs: dict[str, Any] = Factory(dict) + def __init__( self, + *, code: str, redirect_uri: str | None = None, code_verifier: str | None = None, state: str | None = None, nonce: str | None = None, - acr_values: Iterable[str] | None = None, + acr_values: str | Sequence[str] | None = None, max_age: int | None = None, + issuer: str | None = None, **kwargs: str, ): - self.code = code - self.redirect_uri = redirect_uri - self.code_verifier = code_verifier - self.state = state - self.nonce = nonce - self.acr_values = list(acr_values) if acr_values is not None else None - self.max_age = max_age - self.others = kwargs + if not acr_values: + acr_values = None + elif isinstance(acr_values, str): + acr_values = tuple(acr_values.split(" ")) + else: + acr_values = tuple(acr_values) + + self.__attrs_init__( + code=code, + redirect_uri=redirect_uri, + code_verifier=code_verifier, + state=state, + nonce=nonce, + acr_values=acr_values, + max_age=max_age, + issuer=issuer, + kwargs=kwargs, + ) def __getattr__(self, item: str) -> str | None: """Make additional parameters available as attributes. @@ -172,46 +185,83 @@ def __getattr__(self, item: str) -> str | None: Returns: the attribute value, or None if it isn't part of the returned attributes + """ - return self.others.get(item) + return self.kwargs.get(item) +@frozen(init=False) class AuthorizationRequest: - """Represents an Authorization Request. + """Represent an Authorization Request. - This class makes it easy to generate valid Authorization Request URI (possibly including a state, nonce, PKCE, and custom args), - to store all parameters, and to validate an Authorization Response. + This class makes it easy to generate valid Authorization Request URI (possibly including a + state, nonce, PKCE, and custom args), to store all parameters, and to validate an Authorization + Response. All parameters passed at init time will be included in the request query parameters as-is, excepted for a few parameters which have a special behaviour: - * `state`: if True (default), a random state parameter will be generated for you. You may pass your own state as `str`, - or set it to `None` so that the state parameter will not be included in the request. You may access that state in the - `state` attribute from this request. - * `nonce`: if True (default) and scope includes 'openid', a random nonce will be generated and included in the request. - You may access that nonce in the `nonce` attribute from this request. - * `code_verifier`: if `None`, and `code_challenge_method` is `'S256'` or `'plain'`, a valid `code_challenge` - and `code_verifier` for PKCE will be automatically generated, and the `code_challenge` will be included - in the request. You may pass your own `code_verifier` as a `str` parameter, in which case the appropriate - `code_challenge` will be included in the request, according to the `code_challenge_method`. + - `state`: if `...` (default), a random `state` parameter will be generated for you. + You may pass your own `state` as `str`, or set it to `None` so that the `state` parameter + will not be included in the request. You may access that state in the `state` attribute + from this request. + - `nonce`: if `...` (default) and `scope` includes 'openid', a random `nonce` will be + generated and included in the request. You may access that `nonce` in the `nonce` attribute + from this request. + - `code_verifier`: if `None`, and `code_challenge_method` is `'S256'` or `'plain'`, + a valid `code_challenge` and `code_verifier` for PKCE will be automatically generated, + and the `code_challenge` will be included in the request. + You may pass your own `code_verifier` as a `str` parameter, in which case the + appropriate `code_challenge` will be included in the request, according to the + `code_challenge_method`. + - `authorization_response_iss_parameter_supported` and `issuer`: + those are used for Server Issuer Identification. If `ìssuer` is set and an issuer is + included in the Authorization Response, then the consistency between those 2 values will be + checked when using `validate_callback()`. If issuer is not included in the response, and + `authorization_response_iss_parameter_supported` is `False` (default), then no issuer check + is performed. Set `authorization_response_iss_parameter_supported` + to `True` to enforce server identification: if no issuer is included in the Authorization + Response, then an error will be raised instead. Args: authorization_endpoint: the uri for the authorization endpoint. client_id: the client_id to include in the request. redirect_uri: the redirect_uri to include in the request. This is required in OAuth 2.0 and optional - in OAuth 2.1. Pass `None` if you don't need any redirect_uri in the Authorization Request. + in OAuth 2.1. Pass `None` if you don't need any redirect_uri in the Authorization + Request. scope: the scope to include in the request, as an iterable of `str`, or a single space-separated `str`. response_type: the response type to include in the request. - state: the state to include in the request, or `True` to autogenerate one (default). - nonce: the nonce to include in the request, or `True` to autogenerate one (default). - code_verifier: the code verifier to include in the request. If left as `None` and `code_challenge_method` is set, a valid code_verifier will be generated. + state: the state to include in the request, or `...` to autogenerate one (default). + nonce: the nonce to include in the request, or `...` to autogenerate one (default). + code_verifier: the code verifier to include in the request. + If left as `None` and `code_challenge_method` is set, a valid code_verifier + will be generated. code_challenge_method: the method to use to derive the `code_challenge` from the `code_verifier`. acr_values: requested Authentication Context Class Reference values. issuer: Issuer Identifier value from the OAuth/OIDC Server, if using Server Issuer Identification. **kwargs: extra parameters to include in the request, as-is. + """ - exception_classes: dict[str, type[Exception]] = { + authorization_endpoint: str + + client_id: str = field(metadata={"query": True}) + redirect_uri: str | None = field(metadata={"query": True}, default=None) + scope: tuple[str, ...] | None = field(metadata={"query": True}, default=("openid",)) + response_type: str = field(metadata={"query": True}, default="code") + state: str | None = field(metadata={"query": True}, default=None) + nonce: str | None = field(metadata={"query": True}, default=None) + code_challenge_method: str | None = field(metadata={"query": True}, default="S256") + acr_values: tuple[str, ...] | None = field(metadata={"query": True}, default=None) + max_age: int | None = field(metadata={"query": True}, default=None) + kwargs: dict[str, Any] = Factory(dict) + + code_verifier: str | None = None + code_challenge: str | None = field(init=False, metadata={"query": True}) + authorization_response_iss_parameter_supported: bool = False + issuer: str | None = None + + exception_classes: ClassVar[dict[str, type[Exception]]] = { "interaction_required": InteractionRequired, "login_required": LoginRequired, "session_selection_required": SessionSelectionRequired, @@ -228,15 +278,16 @@ def generate_nonce(cls) -> str: """Generate a random `nonce`.""" return secrets.token_urlsafe(32) - def __init__( + def __init__( # noqa: PLR0913, C901 self, authorization_endpoint: str, + *, client_id: str, redirect_uri: str | None = None, scope: None | str | Iterable[str] = "openid", response_type: str = "code", - state: str | Literal[True] | None = True, - nonce: str | Literal[True] | None = True, + state: str | ellipsis | None = ..., # noqa: F821 + nonce: str | ellipsis | None = ..., # noqa: F821 code_verifier: str | None = None, code_challenge_method: str | None = "S256", acr_values: str | Iterable[str] | None = None, @@ -246,109 +297,156 @@ def __init__( **kwargs: Any, ) -> None: if authorization_response_iss_parameter_supported and not issuer: - raise ValueError( - "When 'authorization_response_iss_parameter_supported' is True, you must provide the expected 'issuer' as parameter." + msg = ( + "When 'authorization_response_iss_parameter_supported' is `True`, you must" + " provide the expected `issuer` as parameter." ) + raise ValueError(msg) - if state is True: + if state is Ellipsis: state = self.generate_state() - if scope is not None: - if isinstance(scope, str): - scope = scope.split(" ") - else: - scope = tuple(scope) + if nonce is Ellipsis: + nonce = self.generate_nonce() if scope is not None and "openid" in scope else None - if nonce is True: - if scope is not None and "openid" in scope: - nonce = self.generate_nonce() - else: - nonce = None + if not scope: + scope = None + + if scope is not None: + scope = tuple(scope.split(" ")) if isinstance(scope, str) else tuple(scope) if acr_values is not None: - if isinstance(acr_values, str): - acr_values = acr_values.split() - elif not isinstance(acr_values, list): - acr_values = list(acr_values) + acr_values = tuple(acr_values.split()) if isinstance(acr_values, str) else tuple(acr_values) + + if max_age is not None and max_age < 0: + msg = "The `max_age` parameter is a number of seconds and cannot be negative." + raise ValueError(msg) if "code_challenge" in kwargs: - raise ValueError( - "A code_challenge must not be passed as parameter. " - "Pass the code_verifier instead, and the appropriate code_challenge " - "will automatically be derived from it and included in the request, " - "based on code_challenge_method." + msg = ( + "A `code_challenge` must not be passed as parameter. Pass the `code_verifier`" + " instead, and the appropriate `code_challenge` will automatically be derived" + " from it and included in the request, based on `code_challenge_method`." ) + raise ValueError(msg) - if not code_challenge_method: - code_verifier = code_challenge = code_challenge_method = None - else: + code_challenge: str | None = None + if code_challenge_method: if not code_verifier: code_verifier = PkceUtils.generate_code_verifier() code_challenge = PkceUtils.derive_challenge(code_verifier, code_challenge_method) + else: + code_verifier = None - if max_age is not None: - if max_age < 0: - raise ValueError( - "The `max_age` parameter is a number of seconds and cannot be negative." - ) - - self.authorization_endpoint = authorization_endpoint - self.client_id = client_id - self.redirect_uri = redirect_uri - self.issuer = issuer - self.response_type = response_type - self.scope = scope - self.state = state - self.nonce = nonce - self.code_verifier = code_verifier - self.code_challenge = code_challenge - self.code_challenge_method = code_challenge_method - self.acr_values = acr_values - self.max_age = max_age - self.authorization_response_iss_parameter_supported = ( - authorization_response_iss_parameter_supported - ) - self.kwargs = kwargs - - self.args = dict( + self.__attrs_init__( + authorization_endpoint=authorization_endpoint, client_id=client_id, redirect_uri=redirect_uri, + issuer=issuer, response_type=response_type, - scope=" ".join(scope) if scope is not None else None, + scope=scope, state=state, nonce=nonce, - code_challenge=code_challenge, + code_verifier=code_verifier, code_challenge_method=code_challenge_method, - acr_values=" ".join(acr_values) if acr_values is not None else None, + acr_values=acr_values, max_age=max_age, - **kwargs, + authorization_response_iss_parameter_supported=authorization_response_iss_parameter_supported, + kwargs=kwargs, ) + object.__setattr__(self, "code_challenge", code_challenge) - def as_dict(self) -> Mapping[str, Any]: - """Return a dict with all the parameters used to init this Authorization Request. + def as_dict(self) -> dict[str, Any]: + """Return the full argument dict. - Used for serialization of this request. A new AuthorizationRequest initialized with the same parameters will be - equal to this one. + This can be used to serialize this request and/or to initialize a similar request. + + """ + d = asdict(self) + d.update(**d.pop("kwargs", {})) + d.pop("code_challenge") + return d + + @property + def args(self) -> dict[str, Any]: + """Return a dict with all the query parameters from this AuthorizationRequest. Returns: a dict of parameters + + """ + d = {field.name: getattr(self, field.name) for field in fields(type(self)) if field.metadata.get("query")} + if d["scope"]: + d["scope"] = " ".join(d["scope"]) + d.update(self.kwargs) + + return {key: val for key, val in d.items() if val is not None} + + def validate_callback(self, response: str) -> AuthorizationResponse: + """Validate an Authorization Response against this Request. + + Validate a given Authorization Response URI against this Authorization Request, and return + an + [AuthorizationResponse][requests_oauth2client.authorization_request.AuthorizationResponse]. + + This includes matching the `state` parameter, checking for returned errors, and extracting + the returned `code` and other parameters. + + Args: + response: the Authorization Response URI. This can be the full URL, or just the + query parameters (still encoded as x-www-form-urlencoded). + + Returns: + the extracted code, if all checks are successful + + Raises: + MismatchingIssuer: if the 'iss' received from the response does not match the + expected value. + MismatchingState: if the response `state` does not match the expected value. + OAuth2Error: if the response includes an error. + MissingAuthCode: if the response does not contain a `code`. + NotImplementedError: if response_type anything else than 'code'. + """ - return { - "authorization_endpoint": self.authorization_endpoint, - "client_id": self.client_id, - "redirect_uri": self.redirect_uri, - "scope": self.scope, - "response_type": self.response_type, - "state": self.state, - "nonce": self.nonce, - "code_verifier": self.code_verifier, - "code_challenge_method": self.code_challenge_method, - "issuer": self.issuer, - "authorization_response_iss_parameter_supported": self.authorization_response_iss_parameter_supported, - "acr_values": self.acr_values, - "max_age": self.max_age, - **self.kwargs, - } + try: + response_url = furl(response) + except ValueError: + return self.on_response_error(response) + + # validate 'iss' according to RFC9207 + received_issuer = response_url.args.get("iss") + if self.authorization_response_iss_parameter_supported or received_issuer: + if received_issuer is None: + raise MissingIssuer() + if self.issuer and received_issuer != self.issuer: + raise MismatchingIssuer(self.issuer, received_issuer) + + # validate state + requested_state = self.state + if requested_state: + received_state = response_url.args.get("state") + if requested_state != received_state: + raise MismatchingState(requested_state, received_state) + + error = response_url.args.get("error") + if error: + return self.on_response_error(response) + + if "code" in self.response_type: + code: str = response_url.args.get("code") + if code is None: + raise MissingAuthCode() + else: + raise NotImplementedError() + + return AuthorizationResponse( + code_verifier=self.code_verifier, + redirect_uri=self.redirect_uri, + nonce=self.nonce, + acr_values=self.acr_values, + max_age=self.max_age, + **response_url.args, + ) def sign_request_jwt( self, @@ -360,13 +458,15 @@ def sign_request_jwt( Args: jwk: the JWK to use to sign the request - alg: the alg to use to sign the request, if the passed `jwk` has no `alg` parameter. - lifetime: an optional number of seconds of validity for the signed reqeust. If present, `iat` an `exp` claims will be included in the signed JWT. + alg: the alg to use to sign the request, if the provided `jwk` has no `alg` parameter. + lifetime: an optional number of seconds of validity for the signed request. + If present, `iat` an `exp` claims will be included in the signed JWT. Returns: a `Jwt` that contains the signed request object. + """ - claims = {key: val for key, val in self.args.items() if val is not None} + claims = self.args if lifetime: claims["iat"] = Jwt.timestamp() claims["exp"] = Jwt.timestamp(lifetime) @@ -381,6 +481,7 @@ def sign( jwk: Jwk | dict[str, Any], alg: str | None = None, lifetime: int | None = None, + **kwargs: Any, ) -> RequestParameterAuthorizationRequest: """Sign this Authorization Request and return a new one. @@ -388,11 +489,14 @@ def sign( Args: jwk: the JWK to use to sign the request - alg: the alg to use to sign the request, if the passed `jwk` has no `alg` parameter. - lifetime: lifetime of the resulting Jwt (used to calculate the 'exp' claim). By default, don't use an 'exp' claim. + alg: the alg to use to sign the request, if the provided `jwk` has no `alg` parameter. + lifetime: lifetime of the resulting Jwt (used to calculate the 'exp' claim). + By default, don't use an 'exp' claim. + kwargs: additional query parameters to include in the signed authorization request Returns: the signed Authorization Request + """ request_jwt = self.sign_request_jwt(jwk, alg, lifetime) return RequestParameterAuthorizationRequest( @@ -400,6 +504,7 @@ def sign( client_id=self.client_id, request=str(request_jwt), expires_at=request_jwt.expires_at, + **kwargs, ) def sign_and_encrypt_request_jwt( @@ -418,15 +523,17 @@ def sign_and_encrypt_request_jwt( Args: sign_jwk: the JWK to use to sign the request enc_jwk: the JWK to use to encrypt the request - sign_alg: the alg to use to sign the request, if the passed `jwk` has no `alg` parameter. - enc_alg: the alg to use to encrypt the request, if the passed `jwk` has no `alg` parameter. - enc: the encoding to use to encrypt the request, if the passed `jwk` has no `enc` parameter. - lifetime: lifetime of the resulting Jwt (used to calculate the 'exp' claim). By default, do not include an 'exp' claim. + sign_alg: the alg to use to sign the request, if `sign_jwk` has no `alg` parameter. + enc_alg: the alg to use to encrypt the request, if `enc_jwk` has no `alg` parameter. + enc: the encoding to use to encrypt the request. + lifetime: lifetime of the resulting Jwt (used to calculate the 'exp' claim). + By default, do not include an 'exp' claim. Returns: the signed and encrypted request object, as a `jwskate.Jwt` + """ - claims = {key: val for key, val in self.args.items() if val is not None} + claims = self.args if lifetime: claims["iat"] = Jwt.timestamp() claims["exp"] = Jwt.timestamp(lifetime) @@ -455,13 +562,15 @@ def sign_and_encrypt( Args: sign_jwk: the JWK to use to sign the request enc_jwk: the JWK to use to encrypt the request - sign_alg: the alg to use to sign the request, if the passed `jwk` has no `alg` parameter. - enc_alg: the alg to use to encrypt the request, if the passed `jwk` has no `alg` parameter. - enc: the encoding to use to encrypt the request, if the passed `jwk` has no `enc` parameter. - lifetime: lifetime of the resulting Jwt (used to calculate the 'exp' claim). By default, do not include an 'exp' claim. + sign_alg: the alg to use to sign the request, if `sign_jwk` has no `alg` parameter. + enc_alg: the alg to use to encrypt the request, if `enc_jwk` has no `alg` parameter. + enc: the encoding to use to encrypt the request. + lifetime: lifetime of the resulting Jwt (used to calculate the 'exp' claim). + By default, do not include an 'exp' claim. Returns: - the same AuthorizationRequest, with a request object as parameter + a `RequestParameterAuthorizationRequest`, with a request object as parameter + """ request_jwt = self.sign_and_encrypt_request_jwt( sign_jwk=sign_jwk, @@ -477,79 +586,20 @@ def sign_and_encrypt( request=str(request_jwt), ) - def validate_callback(self, response: str) -> AuthorizationResponse: - """Validate an Authorization Response against this Request. - - Validate a given Authorization Response URI against this Authorization - Request, and return an [AuthorizationResponse][requests_oauth2client.authorization_request.AuthorizationResponse]. - - This includes matching the `state` parameter, checking for returned errors, and extracting the returned `code` - and other parameters. - - Args: - response: the Authorization Response URI. This can be the full URL, or just the query parameters. - - Returns: - the extracted code, if all checks are successful - - Raises: - MismatchingIssuer: if the 'iss' received in the response doesn't match the expected value. - MismatchingState: if the response `state` does not match the expected value. - OAuth2Error: if the response includes an error. - MissingAuthCode: if the response does not contain a `code`. - NotImplementedError: if response_type anything else than 'code' - """ - try: - response_url = furl(response) - except ValueError: - return self.on_response_error(response) - - # validate 'iss' according to RFC9207 - received_issuer = response_url.args.get("iss") - if self.authorization_response_iss_parameter_supported or received_issuer: - if received_issuer is None: - raise MissingIssuer() - if self.issuer and received_issuer != self.issuer: - raise MismatchingIssuer(self.issuer, received_issuer) - - # validate state - requested_state = self.state - if requested_state: - received_state = response_url.args.get("state") - if requested_state != received_state: - raise MismatchingState(requested_state, received_state) - - error = response_url.args.get("error") - if error: - return self.on_response_error(response) - - if "code" in self.response_type: - code: str = response_url.args.get("code") - if code is None: - raise MissingAuthCode() - else: - raise NotImplementedError() - - return AuthorizationResponse( - code_verifier=self.code_verifier, - redirect_uri=self.redirect_uri, - nonce=self.nonce, - acr_values=self.acr_values, - max_age=self.max_age, - **response_url.args, - ) - def on_response_error(self, response: str) -> AuthorizationResponse: """Error handler for Authorization Response errors. - Triggered by [validate_callback()][requests_oauth2client.authorization_request.AuthorizationRequest.validate_callback] if the response uri contains - an error. + Triggered by + [validate_callback()][requests_oauth2client.authorization_request.AuthorizationRequest.validate_callback] + if the response uri contains an error. Args: response: the Authorization Response URI. This can be the full URL, or just the query parameters. Returns: - may return a default code that will be returned by `validate_callback`. But this method will most likely raise exceptions instead. + may return a default code that will be returned by `validate_callback`. But this method + will most likely raise exceptions instead. + """ response_url = furl(response) error = response_url.args.get("error") @@ -563,7 +613,7 @@ def furl(self) -> furl: """Return the Authorization Request URI, as a `furl`.""" return furl( self.authorization_endpoint, - args={key: value for key, value in self.args.items() if value is not None}, + args=self.args, ) @property @@ -571,29 +621,16 @@ def uri(self) -> str: """Return the Authorization Request URI, as a `str`.""" return str(self.furl.url) + def __getattr__(self, item: str) -> Any: + """Allow attribute access to extra parameters.""" + return self.kwargs[item] + def __repr__(self) -> str: """Return the Authorization Request URI, as a `str`.""" return self.uri - def __eq__(self, other: Any) -> bool: - """Check if this Authorization Request is the same as another one. - - Args: - other: another AuthorizationRequest, or a url as string - - Returns: - `True` if the other AuthorizationRequest is the same as this one, `False` otherwise - """ - if isinstance(other, AuthorizationRequest): - return ( - self.authorization_endpoint == other.authorization_endpoint - and self.args == other.args - ) - elif isinstance(other, str): - return self.uri == other - return super().__eq__(other) - +@frozen(init=False) class RequestParameterAuthorizationRequest: """Represent an Authorization Request that includes a `request` JWT. @@ -602,8 +639,16 @@ class RequestParameterAuthorizationRequest: client_id: the client_id request: the request JWT expires_at: the expiration date for this request + kwargs: extra parameters to include in the request + """ + authorization_endpoint: str + client_id: str + request: str + expires_at: datetime | None = None + kwargs: dict[str, Any] = Factory(dict) + @accepts_expires_in def __init__( self, @@ -611,18 +656,22 @@ def __init__( client_id: str, request: str, expires_at: datetime | None = None, + **kwargs: Any, ): - self.authorization_endpoint = authorization_endpoint - self.client_id = client_id - self.request = request - self.expires_at = expires_at + self.__attrs_init__( + authorization_endpoint=authorization_endpoint, + client_id=client_id, + request=request, + expires_at=expires_at, + kwargs=kwargs, + ) @property def furl(self) -> furl: """Return the Authorization Request URI, as a `furl` instance.""" return furl( self.authorization_endpoint, - args={"client_id": self.client_id, "request": self.request}, + args={"client_id": self.client_id, "request": self.request, **self.kwargs}, ) @property @@ -630,15 +679,21 @@ def uri(self) -> str: """Return the Authorization Request URI, as a `str`.""" return str(self.furl.url) + def __getattr__(self, item: str) -> Any: + """Allow attribute access to extra parameters.""" + return self.kwargs[item] + def __repr__(self) -> str: """Return the Authorization Request URI, as a `str`. Returns: the Authorization Request URI + """ return self.uri +@frozen(init=False) class RequestUriParameterAuthorizationRequest: """Represent an Authorization Request that includes a `request_uri` parameter. @@ -647,8 +702,16 @@ class RequestUriParameterAuthorizationRequest: client_id: the client_id request_uri: the request_uri expires_at: the expiration date for this request + kwargs: extra parameters to include in the request + """ + authorization_endpoint: str + client_id: str + request_uri: str + expires_at: datetime | None = None + kwargs: dict[str, Any] = Factory(dict) + @accepts_expires_in def __init__( self, @@ -656,18 +719,22 @@ def __init__( client_id: str, request_uri: str, expires_at: datetime | None = None, + **kwargs: Any, ): - self.authorization_endpoint = authorization_endpoint - self.client_id = client_id - self.request_uri = request_uri - self.expires_at = expires_at + self.__attrs_init__( + authorization_endpoint=authorization_endpoint, + client_id=client_id, + request_uri=request_uri, + expires_at=expires_at, + kwargs=kwargs, + ) @property def furl(self) -> furl: """Return the Authorization Request URI, as a `furl` instance.""" return furl( self.authorization_endpoint, - args={"client_id": self.client_id, "request_uri": self.request_uri}, + args={"client_id": self.client_id, "request_uri": self.request_uri, **self.kwargs}, ) @property @@ -675,6 +742,10 @@ def uri(self) -> str: """Return the Authorization Request URI, as a `str`.""" return str(self.furl.url) + def __getattr__(self, item: str) -> Any: + """Allow attribute access to extra parameters.""" + return self.kwargs[item] + def __repr__(self) -> str: """Return the Authorization Request URI, as a `str`.""" return self.uri @@ -685,6 +756,7 @@ class AuthorizationRequestSerializer: You might need to store pending authorization requests in session, either server-side or client- side. This class is here to help you do that. + """ def __init__( @@ -699,19 +771,24 @@ def __init__( def default_dumper(azr: AuthorizationRequest) -> str: """Provide a default dumper implementation. - Serialize an AuthorizationRequest as JSON, then compress with deflate, then encodes - as base64url. + Serialize an AuthorizationRequest as JSON, then compress with deflate, then encodes as + base64url. Args: azr: the `AuthorizationRequest` to serialize Returns: the serialized value + """ - return BinaPy.serialize_to("json", azr.as_dict()).to("deflate").to("b64u").ascii() + d = asdict(azr) + d.update(**d.pop("kwargs", {})) + d.pop("code_challenge") + return BinaPy.serialize_to("json", d).to("deflate").to("b64u").ascii() + @staticmethod def default_loader( - self, serialized: str, azr_class: type[AuthorizationRequest] = AuthorizationRequest + serialized: str, azr_class: type[AuthorizationRequest] = AuthorizationRequest ) -> AuthorizationRequest: """Provide a default deserializer implementation. @@ -719,9 +796,11 @@ def default_loader( Args: serialized: the serialized AuthorizationRequest + azr_class: the class to deserialize the Authorization Request to Returns: an AuthorizationRequest + """ args = BinaPy(serialized).decode_from("b64u").decode_from("deflate").parse_from("json") return azr_class(**args) @@ -734,6 +813,7 @@ def dumps(self, azr: AuthorizationRequest) -> str: Returns: the serialized AuthorizationRequest, as a str + """ return self.dumper(azr) @@ -745,5 +825,6 @@ def loads(self, serialized: str) -> AuthorizationRequest: Returns: the deserialized AuthorizationRequest + """ return self.loader(serialized) diff --git a/requests_oauth2client/backchannel_authentication.py b/requests_oauth2client/backchannel_authentication.py index 7e0a94c..39eb810 100644 --- a/requests_oauth2client/backchannel_authentication.py +++ b/requests_oauth2client/backchannel_authentication.py @@ -4,10 +4,12 @@ Fundation. https://openid.net/specs/openid-client-initiated-backchannel- authentication-core-1_0.html. + """ + from __future__ import annotations -from datetime import datetime, timedelta +from datetime import datetime, timedelta, timezone from typing import TYPE_CHECKING, Any from .pooling import TokenEndpointPoolingJob @@ -21,8 +23,9 @@ class BackChannelAuthenticationResponse: """Represent a BackChannel Authentication Response. - This contains all the parameters that are returned by the AS as a result of a BackChannel Authentication Request, - such as `auth_req_id` (required), and the optional `expires_at`, `interval`, and/or any custom parameters. + This contains all the parameters that are returned by the AS as a result of a BackChannel + Authentication Request, such as `auth_req_id` (required), and the optional `expires_at`, + `interval`, and/or any custom parameters. Args: auth_req_id: the `auth_req_id` as returned by the AS. @@ -30,6 +33,7 @@ class BackChannelAuthenticationResponse: Note that this request also accepts an `expires_in` parameter, in seconds. interval: the Token Endpoint pooling interval, in seconds, as returned by the AS. **kwargs: any additional custom parameters as returned by the AS. + """ @accepts_expires_in @@ -48,20 +52,24 @@ def __init__( def is_expired(self, leeway: int = 0) -> bool | None: """Return `True` if the `auth_req_id` within this response is expired. - Expiration is evaluated at the time of the call. - If there is no "expires_at" hint (which is derived from the `expires_in` hint returned by the AS BackChannel Authentication endpoint), this will return `None`. + Expiration is evaluated at the time of the call. If there is no "expires_at" hint (which is + derived from the `expires_in` hint returned by the AS BackChannel Authentication endpoint), + this will return `None`. Returns: - `True` if the auth_req_id is expired, `False` if it is still valid, `None` if there is no `expires_in` hint. + `True` if the auth_req_id is expired, `False` if it is still valid, `None` if there is + no `expires_in` hint. + """ if self.expires_at: - return datetime.now() - timedelta(seconds=leeway) > self.expires_at + return datetime.now(tz=timezone.utc) - timedelta(seconds=leeway) > self.expires_at return None def __getattr__(self, key: str) -> Any: """Return attributes from this `BackChannelAuthenticationResponse`. - Allows accessing response parameters with `token_response.expires_in` or `token_response.any_custom_attribute`. + Allows accessing response parameters with `token_response.expires_in` or + `token_response.any_custom_attribute`. Args: key: a key @@ -71,11 +79,12 @@ def __getattr__(self, key: str) -> Any: Raises: AttributeError: if the attribute is not present in the response + """ if key == "expires_in": if self.expires_at is None: return None - return int(self.expires_at.timestamp() - datetime.now().timestamp()) + return int(self.expires_at.timestamp() - datetime.now(tz=timezone.utc).timestamp()) return self.other.get(key) or super().__getattribute__(key) @@ -87,24 +96,19 @@ class BackChannelAuthenticationPoolingJob(TokenEndpointPoolingJob): Args: client: an OAuth2Client that will be used to pool the token endpoint. auth_req_id: an `auth_req_id` as `str` or a `BackChannelAuthenticationResponse`. - interval: The pooling interval to use. This overrides the one in `auth_req_id` if it is a `BackChannelAuthenticationResponse`. - slow_down_interval: Number of seconds to add to the pooling interval when the AS returns a slow down request. + interval: The pooling interval to use. This overrides the one in `auth_req_id` if it is + a `BackChannelAuthenticationResponse`. + slow_down_interval: Number of seconds to add to the pooling interval when the AS returns + a slow down request. requests_kwargs: Additional parameters for the underlying calls to [requests.request][]. **token_kwargs: Additional parameters for the token request. - Usage: - ```python - client = OAuth2Client( - token_endpoint="https://my.as.local/token", auth=("client_id", "client_secret") - ) - pool_job = BackChannelAuthenticationPoolingJob( - client=client, auth_req_id="my_auth_req_id" - ) + Usage: ```python client = OAuth2Client( token_endpoint="https://my.as.local/token", + auth=("client_id", "client_secret") ) pool_job = BackChannelAuthenticationPoolingJob( + client=client, auth_req_id="my_auth_req_id" ) + + token = None while token is None: token = pool_job() ``` - token = None - while token is None: - token = pool_job() - ``` """ def __init__( @@ -136,7 +140,6 @@ def token_request(self) -> BearerToken: Returns: a [BearerToken][requests_oauth2client.tokens.BearerToken] + """ - return self.client.ciba( - self.auth_req_id, requests_kwargs=self.requests_kwargs, **self.token_kwargs - ) + return self.client.ciba(self.auth_req_id, requests_kwargs=self.requests_kwargs, **self.token_kwargs) diff --git a/requests_oauth2client/client.py b/requests_oauth2client/client.py index 0ce0912..ba21ca8 100644 --- a/requests_oauth2client/client.py +++ b/requests_oauth2client/client.py @@ -1,11 +1,13 @@ """This module contains the `OAuth2Client` class.""" + from __future__ import annotations -from typing import Any, Callable, Iterable, TypeVar +from enum import Enum +from typing import Any, Callable, ClassVar, Iterable, TypeVar import requests -from jwskate import Jwk, JwkSet, Jwt -from typing_extensions import Literal +from attrs import field, frozen +from jwskate import Jwk, JwkSet, Jwt, SignatureAlgs from .auth import BearerAuth from .authorization_request import ( @@ -41,51 +43,70 @@ UnknownTokenEndpointError, UnsupportedTokenType, ) -from .tokens import BearerToken, IdToken +from .tokens import BearerToken, IdToken, TokenType from .utils import validate_endpoint_uri T = TypeVar("T") +@frozen(init=False) class OAuth2Client: - """An OAuth 2.x client, that can send requests to an OAuth 2.x Authorization Server. + """An OAuth 2.x Client, that can send requests to an OAuth 2.x Authorization Server. - `OAuth2Client` is able to obtain tokens from the Token Endpoint using any of the standardised Grant Types, - and to communicate with the various backend endpoints like the Revocation, Introspection, and UserInfo Endpoint. + `OAuth2Client` is able to obtain tokens from the Token Endpoint using any of the standardised + Grant Types, and to communicate with the various backend endpoints like the Revocation, + Introspection, and UserInfo Endpoint. - To init an OAuth2Client, you only need the url to the Token Endpoint and the Credentials that will be used to authenticate - to that endpoint. Other endpoint urls, such as the can be passed as parameter as well if you intend to use them. + To init an OAuth2Client, you only need the url to the Token Endpoint and the Credentials + (a client_id and one of a secret or private_key) that will be used to authenticate to that endpoint. + Other endpoint urls, such as the Authorization Endpoint, Revocation Endpoint, etc. can be passed as + parameter as well if you intend to use them. - This class is not intended to help with the end-user authentication or any request that goes in a browser. - For authentication requests, see [AuthorizationRequest][requests_oauth2client.authorization_request.AuthorizationRequest]. - You may use the helper method `authorization_request()` to generate `AuthorizationRequest`s with the preconfigured - `authorization_endpoint`, `client_id` and `redirect_uri' from this client. + This class is not intended to help with the end-user authentication or any request that goes in + a browser. For authentication requests, see + [AuthorizationRequest][requests_oauth2client.authorization_request.AuthorizationRequest]. You + may use the method `authorization_request()` to generate `AuthorizationRequest`s with the + preconfigured `authorization_endpoint`, `client_id` and `redirect_uri' from this client. Args: token_endpoint: the Token Endpoint URI where this client will get access tokens - auth: the authentication handler to use for client authentication on the token endpoint. Can be a [requests.auth.AuthBase][] instance (which will be as-is), or a tuple of `(client_id, client_secret)` which will initialize an instance of [ClientSecretPost][requests_oauth2client.client_authentication.ClientSecretPost], a `(client_id, jwk)` to initialize a [PrivateKeyJwt][requests_oauth2client.client_authentication.PrivateKeyJwt], or a `client_id` which will use [PublicApp][requests_oauth2client.client_authentication.PublicApp] authentication. - client_id: client ID - client_secret: client secret - private_key: private_key to use for client authentication + auth: the authentication handler to use for client authentication on the token endpoint. + Can be: + + - a [requests.auth.AuthBase][] instance (which will be used as-is) + - a tuple of `(client_id, client_secret)` which will initialize an instance + of [ClientSecretPost][requests_oauth2client.client_authentication.ClientSecretPost] + - a `(client_id, jwk)` to initialize + a [PrivateKeyJwt][requests_oauth2client.client_authentication.PrivateKeyJwt], + - or a `client_id` which will + use [PublicApp][requests_oauth2client.client_authentication.PublicApp] authentication. + + client_id: client ID (use either this or `auth`) + client_secret: client secret (use either this or `auth`) + private_key: private_key to use for client authentication (use either this or `auth`) revocation_endpoint: the Revocation Endpoint URI to use for revoking tokens introspection_endpoint: the Introspection Endpoint URI to use to get info about tokens userinfo_endpoint: the Userinfo Endpoint URI to use to get information about the user - authorization_endpoint: the Authorization Endpoint URI for initializing Authorization Requests + authorization_endpoint: the Authorization Endpoint URI, used for initializing Authorization Requests redirect_uri: the redirect_uri for this client backchannel_authentication_endpoint: the BackChannel Authentication URI device_authorization_endpoint: the Device Authorization Endpoint URI to use to authorize devices jwks_uri: the JWKS URI to use to obtain the AS public keys code_challenge_method: challenge method to use for PKCE (should always be 'S256') - session: a requests Session to use when sending HTTP requests. Useful if some extra parameters such as proxy or client certificate must be used to connect to the AS. - **extra_metadata: additional metadata for this client, unused by this class, but may be used by subclasses. Those will be accessible with the `extra_metadata` attribute. + session: a requests Session to use when sending HTTP requests. + Useful if some extra parameters such as proxy or client certificate must be used + to connect to the AS. + **extra_metadata: additional metadata for this client, unused by this class, but may be + used by subclasses. Those will be accessible with the `extra_metadata` attribute. Usage: ```python client = OAuth2Client( token_endpoint="https://my.as.local/token", revocation_endpoint="https://my.as.local/revoke", - auth=("client_id", "client_secret"), + client_id="client_id", + client_secret="client_secret", ) # once initialized, a client can send requests to its configured endpoints @@ -93,9 +114,33 @@ class OAuth2Client: ac_token = client.authorization_code(code="my_code") client.revoke_access_token(cc_token) ``` + """ - exception_classes: dict[str, type[Exception]] = { + auth: requests.auth.AuthBase = field(converter=client_auth_factory) + token_endpoint: str + revocation_endpoint: str | None + introspection_endpoint: str | None + userinfo_endpoint: str | None + authorization_endpoint: str | None + redirect_uri: str | None + backchannel_authentication_endpoint: str | None + device_authorization_endpoint: str | None + pushed_authorization_request_endpoint: str | None + jwks_uri: str | None + authorization_server_jwks: JwkSet + issuer: str | None + id_token_signed_response_alg: str | None = SignatureAlgs.RS256 + id_token_encrypted_response_alg: str | None = None + id_token_decryption_key: Jwk | None = None + code_challenge_method: str | None = "S256" + authorization_response_iss_parameter_supported: bool = False + session: requests.Session = field(factory=requests.Session) + extra_metadata: dict[str, Any] = field(factory=dict) + + bearer_token_class: type[BearerToken] = BearerToken + + exception_classes: ClassVar[dict[str, type[Exception]]] = { "server_error": ServerError, "invalid_request": InvalidRequest, "invalid_client": InvalidClient, @@ -110,18 +155,11 @@ class OAuth2Client: "unsupported_token_type": UnsupportedTokenType, } - token_class: type[BearerToken] = BearerToken - - def __init__( + def __init__( # noqa: PLR0913 self, token_endpoint: str, auth: ( - requests.auth.AuthBase - | tuple[str, str] - | tuple[str, Jwk] - | tuple[str, dict[str, Any]] - | str - | None + requests.auth.AuthBase | tuple[str, str] | tuple[str, Jwk] | tuple[str, dict[str, Any]] | str | None ) = None, *, client_id: str | None = None, @@ -138,75 +176,84 @@ def __init__( jwks_uri: str | None = None, authorization_server_jwks: JwkSet | dict[str, Any] | None = None, issuer: str | None = None, - id_token_signed_response_alg: str | None = "RS256", + id_token_signed_response_alg: str | None = SignatureAlgs.RS256, id_token_encrypted_response_alg: str | None = None, id_token_decryption_key: Jwk | dict[str, Any] | None = None, code_challenge_method: str = "S256", authorization_response_iss_parameter_supported: bool = False, + bearer_token_class: type[BearerToken] = BearerToken, session: requests.Session | None = None, **extra_metadata: Any, ): if authorization_response_iss_parameter_supported and not issuer: - raise ValueError( - "If the Authorization Server supports Issuer Identification, " - "as specified by `authorization_response_iss_parameter_supported=True`, " - "then you must specify the expected `issuer` value with parameter `issuer`." + msg = ( + "If the Authorization Server supports Issuer Identification, as specified by" + " `authorization_response_iss_parameter_supported=True`, then you must specify" + " the expected `issuer` value with parameter `issuer`." ) - self.token_endpoint = str(token_endpoint) - self.revocation_endpoint = str(revocation_endpoint) if revocation_endpoint else None - self.introspection_endpoint = ( - str(introspection_endpoint) if introspection_endpoint else None - ) - self.userinfo_endpoint = str(userinfo_endpoint) if userinfo_endpoint else None - self.authorization_endpoint = ( - str(authorization_endpoint) if authorization_endpoint else None - ) - self.redirect_uri = str(redirect_uri) if redirect_uri else None - self.backchannel_authentication_endpoint = ( - str(backchannel_authentication_endpoint) - if backchannel_authentication_endpoint - else None - ) - self.device_authorization_endpoint = ( - str(device_authorization_endpoint) if device_authorization_endpoint else None - ) - self.pushed_authorization_request_endpoint = ( - str(pushed_authorization_request_endpoint) - if pushed_authorization_request_endpoint - else None - ) - self.jwks_uri = str(jwks_uri) if jwks_uri else None - self.authorization_server_jwks = ( - JwkSet(authorization_server_jwks) if authorization_server_jwks else None - ) - self.issuer = str(issuer) if issuer else None - self.session = session or requests.Session() - self.auth = client_auth_factory( + raise ValueError(msg) + + auth = client_auth_factory( auth, client_id=client_id, client_secret=client_secret, private_key=private_key, default_auth_handler=ClientSecretPost, ) - self.id_token_signed_response_alg = id_token_signed_response_alg - self.id_token_encrypted_response_alg = id_token_encrypted_response_alg - self.id_token_decryption_key = ( - Jwk(id_token_decryption_key) if id_token_decryption_key else None - ) - self.code_challenge_method = code_challenge_method - self.authorization_response_iss_parameter_supported = ( - authorization_response_iss_parameter_supported + + if authorization_server_jwks is None: + authorization_server_jwks = JwkSet() + elif not isinstance(authorization_server_jwks, JwkSet): + authorization_server_jwks = JwkSet(authorization_server_jwks) + + if id_token_decryption_key is not None and not isinstance(id_token_decryption_key, Jwk): + id_token_decryption_key = Jwk(id_token_decryption_key) + + if id_token_decryption_key is not None and id_token_encrypted_response_alg is None: + if id_token_decryption_key.alg: + id_token_encrypted_response_alg = id_token_decryption_key.alg + else: + msg = ( + "An ID Token decryption key has been provided but no decryption algorithm is defined." + " You can either pass an `id_token_encrypted_response_alg` parameter with the alg identifier," + " or include an `alg` attribute in the decryption key, if it is in Jwk format." + ) + raise ValueError(msg) + + if session is None: + session = requests.Session() + + self.__attrs_init__( + token_endpoint=token_endpoint, + revocation_endpoint=revocation_endpoint, + introspection_endpoint=introspection_endpoint, + userinfo_endpoint=userinfo_endpoint, + authorization_endpoint=authorization_endpoint, + redirect_uri=redirect_uri, + backchannel_authentication_endpoint=backchannel_authentication_endpoint, + device_authorization_endpoint=device_authorization_endpoint, + pushed_authorization_request_endpoint=pushed_authorization_request_endpoint, + jwks_uri=jwks_uri, + authorization_server_jwks=authorization_server_jwks, + issuer=issuer, + session=session, + auth=auth, + id_token_signed_response_alg=id_token_signed_response_alg, + id_token_encrypted_response_alg=id_token_encrypted_response_alg, + id_token_decryption_key=id_token_decryption_key, + code_challenge_method=code_challenge_method, + authorization_response_iss_parameter_supported=authorization_response_iss_parameter_supported, + bearer_token_class=bearer_token_class, + extra_metadata=extra_metadata, ) - self.extra_metadata = extra_metadata @property def client_id(self) -> str: """Client ID.""" if hasattr(self.auth, "client_id"): return self.auth.client_id # type: ignore[no-any-return] - raise AttributeError( # pragma: no cover - "This client uses a custom authentication method without client_id." - ) + msg = "This client uses a custom authentication method without client_id." + raise AttributeError(msg) # pragma: no cover @property def client_secret(self) -> str | None: @@ -223,6 +270,7 @@ def client_jwks(self) -> JwkSet: - the public key for client assertion signature verification (if using private_key_jwt) - the ID Token encryption key + """ jwks = JwkSet() if isinstance(self.auth, PrivateKeyJwt): @@ -242,7 +290,7 @@ def _request( ) -> T: """Send a request to one of the endpoints. - This is an helper method that takes care of the following tasks: + This is a helper method that takes care of the following tasks: - make sure the endpoint as been configured - set `Accept: application/json` header @@ -258,6 +306,7 @@ def _request( accept: the Accept header to include in the request method: the HTTP method to use **requests_kwargs: keyword arguments for the request + """ endpoint_uri = self._require_endpoint(endpoint) requests_kwargs.setdefault("headers", {}) @@ -274,19 +323,25 @@ def _request( return on_failure(response) def token_request( - self, data: dict[str, Any], timeout: int = 10, **requests_kwargs: Any + self, + data: dict[str, Any], + timeout: int = 10, + **requests_kwargs: Any, ) -> BearerToken: """Send a request to the token endpoint. Authentication will be added automatically based on the defined `auth` for this client. Args: - data: parameters to send to the token endpoint. Items with a None or empty value will not be sent in the request. - timeout: a timeout value for the call - **requests_kwargs: additional parameters for requests.post() + data: parameters to send to the token endpoint. Items with a `None` + or empty value will not be sent in the request. + timeout: a timeout value for the call + **requests_kwargs: additional parameters for requests.post() Returns: - the token endpoint response, as [`BearerToken`][requests_oauth2client.tokens.BearerToken] instance. + the token endpoint response, as + [`BearerToken`][requests_oauth2client.tokens.BearerToken] instance. + """ return self._request( "token_endpoint", @@ -301,34 +356,42 @@ def token_request( def parse_token_response(self, response: requests.Response) -> BearerToken: """Parse a Response returned by the Token Endpoint. - Invoked by [token_request][requests_oauth2client.client.OAuth2Client.token_request] to parse responses returned by the Token Endpoint. - Those response contain an `access_token` and additional attributes. + Invoked by [token_request][requests_oauth2client.client.OAuth2Client.token_request] to parse + responses returned by the Token Endpoint. Those responses contain an `access_token` and + additional attributes. Args: response: the [Response][requests.Response] returned by the Token Endpoint. Returns: - a [`BearerToken`][requests_oauth2client.tokens.BearerToken] based on the response contents. + a [`BearerToken`][requests_oauth2client.tokens.BearerToken] based on the response + contents. + """ try: - token_response = self.token_class(**response.json()) - return token_response + token_response = self.bearer_token_class(**response.json()) except Exception as response_class_exc: try: return self.on_token_error(response) except Exception as token_error_exc: raise token_error_exc from response_class_exc + else: + return token_response def on_token_error(self, response: requests.Response) -> BearerToken: """Error handler for `token_request()`. - Invoked by [token_request][requests_oauth2client.client.OAuth2Client.token_request] when the Token Endpoint returns an error. + Invoked by [token_request][requests_oauth2client.client.OAuth2Client.token_request] when the + Token Endpoint returns an error. Args: response: the [Response][requests.Response] returned by the Token Endpoint. Returns: - nothing, and raises an exception instead. But a subclass may return a [`BearerToken`][requests_oauth2client.tokens.BearerToken] to implement a default behaviour if needed. + nothing, and raises an exception instead. But a subclass may return a + [`BearerToken`][requests_oauth2client.tokens.BearerToken] to implement a default + behaviour if needed. + """ try: data = response.json() @@ -344,6 +407,7 @@ def on_token_error(self, response: requests.Response) -> BearerToken: def client_credentials( self, scope: str | Iterable[str] | None = None, + *, requests_kwargs: dict[str, Any] | None = None, **token_kwargs: Any, ) -> BearerToken: @@ -357,21 +421,24 @@ def client_credentials( Returns: a TokenResponse + """ requests_kwargs = requests_kwargs or {} - if scope is not None and not isinstance(scope, str): + if scope and not isinstance(scope, str): try: scope = " ".join(scope) except Exception as exc: - raise ValueError("Unsupported scope value") from exc + msg = "Unsupported scope value" + raise ValueError(msg) from exc - data = dict(grant_type="client_credentials", scope=scope, **token_kwargs) + data = dict(grant_type=GrantType.CLIENT_CREDENTIALS, scope=scope, **token_kwargs) return self.token_request(data, **requests_kwargs) def authorization_code( self, code: str | AuthorizationResponse, + *, validate: bool = True, requests_kwargs: dict[str, Any] | None = None, **token_kwargs: Any, @@ -386,6 +453,7 @@ def authorization_code( Returns: a `BearerToken` + """ azr: AuthorizationResponse | None = None if isinstance(code, AuthorizationResponse): @@ -396,10 +464,10 @@ def authorization_code( requests_kwargs = requests_kwargs or {} - data = dict(grant_type="authorization_code", code=code, **token_kwargs) + data = dict(grant_type=GrantType.AUTHORIZATION_CODE, code=code, **token_kwargs) token = self.token_request(data, **requests_kwargs) if validate and token.id_token and isinstance(azr, AuthorizationResponse): - token.validate_id_token(self, azr) + return token.validate_id_token(self, azr) return token def refresh_token( @@ -411,22 +479,24 @@ def refresh_token( """Send a request to the token endpoint with the `refresh_token` grant. Args: - refresh_token: a refresh_token, as a string, or as a `BearerToken`. That `BearerToken` must have a `refresh_token`. + refresh_token: a refresh_token, as a string, or as a `BearerToken`. + That `BearerToken` must have a `refresh_token`. requests_kwargs: additional parameters for the call to `requests` - **token_kwargs: additional parameters for the token endpoint, alongside `grant_type`, `refresh_token`, etc. + **token_kwargs: additional parameters for the token endpoint, + alongside `grant_type`, `refresh_token`, etc. Returns: a `BearerToken` + """ if isinstance(refresh_token, BearerToken): - if refresh_token.refresh_token is None or not isinstance( - refresh_token.refresh_token, str - ): - raise ValueError("This BearerToken doesn't have a refresh_token") + if refresh_token.refresh_token is None or not isinstance(refresh_token.refresh_token, str): + msg = "This BearerToken doesn't have a refresh_token" + raise ValueError(msg) refresh_token = refresh_token.refresh_token requests_kwargs = requests_kwargs or {} - data = dict(grant_type="refresh_token", refresh_token=refresh_token, **token_kwargs) + data = dict(grant_type=GrantType.REFRESH_TOKEN, refresh_token=refresh_token, **token_kwargs) return self.token_request(data, **requests_kwargs) def device_code( @@ -437,8 +507,8 @@ def device_code( ) -> BearerToken: """Send a request to the token endpoint using the Device Code grant. - The grant_type is `urn:ietf:params:oauth:grant-type:device_code`. - This needs a Device Code, or a `DeviceAuthorizationResponse` as parameter. + The grant_type is `urn:ietf:params:oauth:grant-type:device_code`. This needs a Device Code, + or a `DeviceAuthorizationResponse` as parameter. Args: device_code: a device code, or a `DeviceAuthorizationResponse` @@ -447,15 +517,17 @@ def device_code( Returns: a `BearerToken` + """ if isinstance(device_code, DeviceAuthorizationResponse): if device_code.device_code is None or not isinstance(device_code.device_code, str): - raise ValueError("This DeviceAuthorizationResponse doesn't have a device_code") + msg = "This DeviceAuthorizationResponse doesn't have a device_code" + raise ValueError(msg) device_code = device_code.device_code requests_kwargs = requests_kwargs or {} data = dict( - grant_type="urn:ietf:params:oauth:grant-type:device_code", + grant_type=GrantType.DEVICE_CODE, device_code=device_code, **token_kwargs, ) @@ -478,17 +550,17 @@ def ciba( Returns: a `BearerToken` + """ if isinstance(auth_req_id, BackChannelAuthenticationResponse): if auth_req_id.auth_req_id is None or not isinstance(auth_req_id.auth_req_id, str): - raise ValueError( - "This BackChannelAuthenticationResponse doesn't have an auth_req_id" - ) + msg = "This `BackChannelAuthenticationResponse` doesn't have an `auth_req_id`" + raise ValueError(msg) auth_req_id = auth_req_id.auth_req_id requests_kwargs = requests_kwargs or {} data = dict( - grant_type="urn:openid:params:grant-type:ciba", + grant_type=GrantType.CLIENT_INITIATED_BACKCHANNEL_AUTHENTICATION, auth_req_id=auth_req_id, **token_kwargs, ) @@ -506,7 +578,8 @@ def token_exchange( ) -> BearerToken: """Send a Token Exchange request. - A Token Exchange request is actually a request to the Token Endpoint with a grant_type `urn:ietf:params:oauth:grant-type:token-exchange`. + A Token Exchange request is actually a request to the Token Endpoint with a grant_type + `urn:ietf:params:oauth:grant-type:token-exchange`. Args: subject_token: the subject token to exchange for a new token. @@ -521,27 +594,24 @@ def token_exchange( Returns: a `BearerToken` as returned by the Authorization Server. + """ requests_kwargs = requests_kwargs or {} try: subject_token_type = self.get_token_type(subject_token_type, subject_token) except ValueError: - raise TypeError( - "Cannot determine the kind of 'subject_token' you provided. " - "Please specify a 'subject_token_type'." - ) + msg = "Cannot determine the kind of 'subject_token' you provided. Please specify a 'subject_token_type'." + raise TypeError(msg) from None if actor_token: # pragma: no branch try: actor_token_type = self.get_token_type(actor_token_type, actor_token) except ValueError: - raise TypeError( - "Cannot determine the kind of 'actor_token' you provided. " - "Please specify an 'actor_token_type'." - ) + msg = "Cannot determine the kind of 'actor_token' you provided. Please specify an 'actor_token_type'." + raise TypeError(msg) from None data = dict( - grant_type="urn:ietf:params:oauth:grant-type:token-exchange", + grant_type=GrantType.TOKEN_EXCHANGE, subject_token=subject_token, subject_token_type=subject_token_type, actor_token=actor_token, @@ -568,6 +638,7 @@ def jwt_bearer( Returns: a `BearerToken` as returned by the Authorization Server. + """ requests_kwargs = requests_kwargs or {} @@ -575,7 +646,7 @@ def jwt_bearer( assertion = Jwt(assertion) data = dict( - grant_type="urn:ietf:params:oauth:grant-type:jwt-bearer", + grant_type=GrantType.JWT_BEARER, assertion=assertion, **token_kwargs, ) @@ -601,10 +672,11 @@ def resource_owner_password( Returns: a `BearerToken` as returned by the Authorization Server + """ requests_kwargs = requests_kwargs or {} data = dict( - grant_type="password", + grant_type=GrantType.RESOURCE_OWNER_PASSWORD, username=username, password=password, **token_kwargs, @@ -614,40 +686,45 @@ def resource_owner_password( def authorization_request( self, + *, scope: None | str | Iterable[str] = "openid", response_type: str = "code", redirect_uri: str | None = None, - state: str | Literal[True] | None = True, - nonce: str | Literal[True] | None = True, + state: str | ellipsis | None = ..., # noqa: F821 + nonce: str | ellipsis | None = ..., # noqa: F821 code_verifier: str | None = None, **kwargs: Any, ) -> AuthorizationRequest: """Generate an Authorization Request for this client. Args: - scope: the scope to use - response_type: the response_type to use - redirect_uri: the redirect_uri to include in the request. By default, the redirect_uri defined at init time is used. - state: the state parameter to use. Leave default to generate a random value. - nonce: a nonce. Leave default to generate a random value. - code_verifier: the PKCE code verifier to use. Leave default to generate a random value. + scope: the `scope` to use + response_type: the `response_type` to use + redirect_uri: the `redirect_uri` to include in the request. By default, + the `redirect_uri` defined at init time is used. + state: the `state` parameter to use. Leave default to generate a random value. + nonce: a `nonce`. Leave default to generate a random value. + code_verifier: the PKCE `code_verifier` to use. Leave default to generate a random value. **kwargs: additional parameters to include in the auth request Returns: an AuthorizationRequest with the supplied parameters + """ authorization_endpoint = self._require_endpoint("authorization_endpoint") redirect_uri = redirect_uri or self.redirect_uri if not redirect_uri: - raise AttributeError( - "No 'redirect_uri' defined for this client. " - "You must either pass a redirect_uri as parameter to this method, " - "or include a redirect_uri when initializing your OAuth2Client." + msg = ( + "No 'redirect_uri' defined for this client. You must either pass a redirect_uri" + " as parameter to this method, or include a redirect_uri when initializing your" + " OAuth2Client." ) + raise AttributeError(msg) if response_type != "code": - raise ValueError("Only response_type=code is supported.") + msg = "Only response_type=code is supported." + raise ValueError(msg) return AuthorizationRequest( authorization_endpoint=authorization_endpoint, @@ -675,9 +752,11 @@ def pushed_authorization_request( Args: authorization_request: the authorization request to send + requests_kwargs: additional parameters for `requests.request()` Returns: the `RequestUriParameterAuthorizationRequest` initialized based on the AS response + """ requests_kwargs = requests_kwargs or {} return self._request( @@ -699,6 +778,7 @@ def parse_pushed_authorization_response( Returns: a RequestUriParameterAuthorizationRequest instance + """ response_json = response.json() request_uri = response_json.get("request_uri") @@ -724,8 +804,9 @@ def on_pushed_authorization_request_error( Raises: EndpointError: a subclass of this error depending on the error returned by the AS - InvalidPushedAuthorizationResponse: if the returned response is not following the specifications - UnknownTokenEndpointError: for unknown/unhandled errors + InvalidPushedAuthorizationResponse: if the returned response is not following the + specifications UnknownTokenEndpointError: for unknown/unhandled errors + """ try: data = response.json() @@ -741,13 +822,15 @@ def on_pushed_authorization_request_error( def userinfo(self, access_token: BearerToken | str) -> Any: """Call the UserInfo endpoint. - This sends a request to the UserInfo endpoint, with the specified access_token, and returns the parsed result. + This sends a request to the UserInfo endpoint, with the specified access_token, and returns + the parsed result. Args: access_token: the access token to use Returns: the [Response][requests.Response] returned by the userinfo endpoint. + """ return self._request( "userinfo_endpoint", @@ -759,14 +842,15 @@ def userinfo(self, access_token: BearerToken | str) -> Any: def parse_userinfo_response(self, resp: requests.Response) -> Any: """Parse the response obtained by `userinfo()`. - Invoked by [userinfo()][requests_oauth2client.client.OAuth2Client.userinfo] to parse the response from the UserInfo endpoint, this will extract and return its JSON - content. + Invoked by [userinfo()][requests_oauth2client.client.OAuth2Client.userinfo] to parse the + response from the UserInfo endpoint, this will extract and return its JSON content. Args: resp: a [Response][requests.Response] returned from the UserInfo endpoint. Returns: the parsed JSON content from this response. + """ return resp.json() @@ -778,62 +862,66 @@ def on_userinfo_error(self, resp: requests.Response) -> Any: Returns: nothing, raises exception instead. + """ resp.raise_for_status() @classmethod - def get_token_type( + def get_token_type( # noqa: C901 cls, token_type: str | None = None, token: None | str | BearerToken | IdToken = None, ) -> str: - """Get standardised token type identifiers. + """Get standardized token type identifiers. - Return a standardised token type identifier, based on a short `token_type` - hint and/or a token value. + Return a standardized token type identifier, based on a short `token_type` hint and/or a + token value. Args: - token_type: a token_type hint, as `str`. May be "access_token", "refresh_token" or "id_token" (optional) + token_type: a token_type hint, as `str`. May be "access_token", "refresh_token" + or "id_token" token: a token value, as an instance of `BearerToken` or IdToken, or as a `str`. Returns: the token_type as defined in the Token Exchange RFC8693. + """ if not (token_type or token): - raise ValueError( - "Cannot determine type of an empty token without a token_type hint" - ) + msg = "Cannot determine type of an empty token without a token_type hint" + raise ValueError(msg) if token_type is None: if isinstance(token, str): - raise ValueError( - "Cannot determine the type of provided token when it is a bare str. " - "Please specify a token_type." - ) + msg = "Cannot determine the type of provided token when it is a bare str. Please specify a token_type." + raise ValueError(msg) elif isinstance(token, BearerToken): return "urn:ietf:params:oauth:token-type:access_token" elif isinstance(token, IdToken): return "urn:ietf:params:oauth:token-type:id_token" else: + msg = "Unexpected type of token, please provide a string or a BearerToken or an IdToken." raise TypeError( - "Unexpected type of token, please provide a string or a BearerToken or an IdToken.", + msg, type(token), ) - elif token_type == "access_token": + elif token_type == TokenType.ACCESS_TOKEN: if token is not None and not isinstance(token, (str, BearerToken)): + msg = "The supplied token is not a BearerToken or a string representation of it." raise TypeError( - "The supplied token is not a BearerToken or a string representation of it.", + msg, type(token), ) return "urn:ietf:params:oauth:token-type:access_token" - elif token_type == "refresh_token": + elif token_type == TokenType.REFRESH_TOKEN: if token is not None and isinstance(token, BearerToken) and not token.refresh_token: - raise ValueError("The supplied BearerToken doesn't have a refresh_token.") + msg = "The supplied BearerToken doesn't have a refresh_token." + raise ValueError(msg) return "urn:ietf:params:oauth:token-type:refresh_token" elif token_type == "id_token": if token is not None and not isinstance(token, (str, IdToken)): + msg = "The supplied token is not an IdToken or a string representation of it." raise TypeError( - "The supplied token is not an IdToken or a string representation of it.", + msg, type(token), ) return "urn:ietf:params:oauth:token-type:id_token" @@ -856,10 +944,11 @@ def revoke_access_token( access_token: the access token to revoke requests_kwargs: additional parameters for the underlying requests.post() call **revoke_kwargs: additional parameters to pass to the revocation endpoint + """ return self.revoke_token( access_token, - token_type_hint="access_token", + token_type_hint=TokenType.ACCESS_TOKEN, requests_kwargs=requests_kwargs, **revoke_kwargs, ) @@ -878,16 +967,19 @@ def revoke_refresh_token( **revoke_kwargs: additional parameters to pass to the revocation endpoint. Returns: - `True` if the revocation request is successful, `False` if this client has no configured revocation endpoint. + `True` if the revocation request is successful, `False` if this client has no configured + revocation endpoint. + """ if isinstance(refresh_token, BearerToken): if refresh_token.refresh_token is None: - raise ValueError("The supplied BearerToken doesn't have a refresh token.") + msg = "The supplied BearerToken doesn't have a refresh token." + raise ValueError(msg) refresh_token = refresh_token.refresh_token return self.revoke_token( refresh_token, - token_type_hint="refresh_token", + token_type_hint=TokenType.REFRESH_TOKEN, requests_kwargs=requests_kwargs, **revoke_kwargs, ) @@ -910,14 +1002,16 @@ def revoke_token( **revoke_kwargs: additional parameters to send to the revocation endpoint. Returns: - `True` if the revocation succeeds, - `False` if no revocation endpoint is present or a non-standardised error is returned. + `True` if the revocation succeeds, `False` if no revocation endpoint is present or a + non-standardised error is returned. + """ requests_kwargs = requests_kwargs or {} - if token_type_hint == "refresh_token" and isinstance(token, BearerToken): + if token_type_hint == TokenType.REFRESH_TOKEN and isinstance(token, BearerToken): if token.refresh_token is None: - raise ValueError("The supplied BearerToken doesn't have a refresh token.") + msg = "The supplied BearerToken doesn't have a refresh token." + raise ValueError(msg) token = token.refresh_token data = dict(revoke_kwargs, token=str(token)) @@ -936,13 +1030,16 @@ def revoke_token( def on_revocation_error(self, response: requests.Response) -> bool: """Error handler for `revoke_token()`. - Invoked by [revoke_token()][requests_oauth2client.client.OAuth2Client.revoke_token] when the revocation endpoint returns an error. + Invoked by [revoke_token()][requests_oauth2client.client.OAuth2Client.revoke_token] when the + revocation endpoint returns an error. Args: response: the [Response][requests.Response] as returned by the Revocation Endpoint Returns: - `False` to signal that an error occurred. May raise exceptions instead depending on the revocation response. + `False` to signal that an error occurred. May raise exceptions instead depending on the + revocation response. + """ try: data = response.json() @@ -962,22 +1059,49 @@ def introspect_token( requests_kwargs: dict[str, Any] | None = None, **introspect_kwargs: Any, ) -> Any: - """Send a request to the configured Introspection Endpoint. + """Send a request to the Introspection Endpoint. + + Parameter `token` can be: + + - a `str` + - a `BearerToken` instance + + You may pass any arbitrary `token` and `token_type_hint` values as `str`. Those will + be included in the request, as-is. + If `token` is a `BearerToken`, then `token_type_hint` must be either: + + - `None`: the access_token will be instrospected and no token_type_hint will be included + in the request + - `access_token`: same as `None`, but the token_type_hint will be included + - or `refresh_token`: only available if a Refresh Token is present in the BearerToken. Args: - token_type_hint: the token_type_hint to include in the request. + token: the token to instrospect + token_type_hint: the `token_type_hint` to include in the request. requests_kwargs: additional parameters to the underling call to requests.post() **introspect_kwargs: additional parameters to send to the introspection endpoint. Returns: the response as returned by the Introspection Endpoint. + """ requests_kwargs = requests_kwargs or {} - if token_type_hint == "refresh_token" and isinstance(token, BearerToken): - if token.refresh_token is None: - raise ValueError("The supplied BearerToken doesn't have a refresh token.") - token = token.refresh_token + if isinstance(token, BearerToken): + if token_type_hint is None or token_type_hint == TokenType.ACCESS_TOKEN: + token = token.access_token + elif token_type_hint == TokenType.REFRESH_TOKEN: + if token.refresh_token is None: + msg = "The supplied BearerToken doesn't have a refresh token." + raise ValueError(msg) + else: + token = token.refresh_token + else: + msg = ( + "Invalid `token_type_hint`. To test arbitrary `token_type_hint` values," + " you must provide `token` as a `str`." + ) + raise ValueError(msg) data = dict(introspect_kwargs, token=str(token)) if token_type_hint: @@ -995,14 +1119,16 @@ def introspect_token( def parse_introspection_response(self, response: requests.Response) -> Any: """Parse Token Introspection Responses received by `introspect_token()`. - Invoked by [introspect_token()][requests_oauth2client.client.OAuth2Client.introspect_token] to parse the returned response. - This decodes the JSON content if possible, otherwise it returns the response as a string. + Invoked by [introspect_token()][requests_oauth2client.client.OAuth2Client.introspect_token] + to parse the returned response. This decodes the JSON content if possible, otherwise it + returns the response as a string. Args: response: the [Response][requests.Response] as returned by the Introspection Endpoint. Returns: the decoded JSON content, or a `str` with the content. + """ try: return response.json() @@ -1012,13 +1138,15 @@ def parse_introspection_response(self, response: requests.Response) -> Any: def on_introspection_error(self, response: requests.Response) -> Any: """Error handler for `introspect_token()`. - Invoked by [introspect_token()][requests_oauth2client.client.OAuth2Client.introspect_token] to parse the returned response in the case an error is returned. + Invoked by [introspect_token()][requests_oauth2client.client.OAuth2Client.introspect_token] + to parse the returned response in the case an error is returned. Args: response: the response as returned by the Introspection Endpoint. Returns: - usually raises exeptions. A subclass can return a default response instead. + usually raises exceptions. A subclass can return a default response instead. + """ try: data = response.json() @@ -1031,9 +1159,10 @@ def on_introspection_error(self, response: requests.Response) -> Any: raise UnknownIntrospectionError(response) from exc raise exception - def backchannel_authentication_request( + def backchannel_authentication_request( # noqa: PLR0913 self, scope: None | str | Iterable[str] = "openid", + *, client_notification_token: str | None = None, acr_values: None | str | Iterable[str] = None, login_hint_token: str | None = None, @@ -1066,20 +1195,15 @@ def backchannel_authentication_request( Returns: a BackChannelAuthenticationResponse as returned by AS + """ if not (login_hint or login_hint_token or id_token_hint): - raise ValueError( - "One of `login_hint`, `login_hint_token` or `ìd_token_hint` must be provided." - ) + msg = "One of `login_hint`, `login_hint_token` or `ìd_token_hint` must be provided" + raise ValueError(msg) - if ( - (login_hint_token and id_token_hint) - or (login_hint and id_token_hint) - or (login_hint_token and login_hint) - ): - raise ValueError( - "Only one of `login_hint`, `login_hint_token` or `ìd_token_hint` must be provided" - ) + if (login_hint_token and id_token_hint) or (login_hint and id_token_hint) or (login_hint_token and login_hint): + msg = "Only one of `login_hint`, `login_hint_token` or `ìd_token_hint` must be provided" + raise ValueError(msg) requests_kwargs = requests_kwargs or {} @@ -1087,13 +1211,15 @@ def backchannel_authentication_request( try: scope = " ".join(scope) except Exception as exc: - raise ValueError("Unsupported `scope` value") from exc + msg = "Unsupported `scope` value" + raise ValueError(msg) from exc if acr_values is not None and not isinstance(acr_values, str): try: acr_values = " ".join(acr_values) except Exception as exc: - raise ValueError("Unsupported `acr_values`") from exc + msg = "Unsupported `acr_values`" + raise ValueError(msg) from exc data = dict( ciba_kwargs, @@ -1125,33 +1251,36 @@ def parse_backchannel_authentication_response( ) -> BackChannelAuthenticationResponse: """Parse a response received by `backchannel_authentication_request()`. - Invoked by [backchannel_authentication_request()][requests_oauth2client.client.OAuth2Client.backchannel_authentication_request] to parse the response - returned by the BackChannel Authentication Endpoint. + Invoked by + [backchannel_authentication_request()][requests_oauth2client.client.OAuth2Client.backchannel_authentication_request] + to parse the response returned by the BackChannel Authentication Endpoint. Args: response: the response returned by the BackChannel Authentication Endpoint. Returns: a `BackChannelAuthenticationResponse` + """ try: return BackChannelAuthenticationResponse(**response.json()) except TypeError as exc: raise InvalidBackChannelAuthenticationResponse(response) from exc - def on_backchannel_authentication_error( - self, response: requests.Response - ) -> BackChannelAuthenticationResponse: + def on_backchannel_authentication_error(self, response: requests.Response) -> BackChannelAuthenticationResponse: """Error handler for `backchannel_authentication_request()`. - Invoked by [backchannel_authentication_request()][requests_oauth2client.client.OAuth2Client.backchannel_authentication_request] to parse the response - returned by the BackChannel Authentication Endpoint, when it is an error. + Invoked by + [backchannel_authentication_request()][requests_oauth2client.client.OAuth2Client.backchannel_authentication_request] + to parse the response returned by the BackChannel Authentication Endpoint, when it is an + error. Args: response: the response returned by the BackChannel Authentication Endpoint. Returns: usually raises an exception. But a subclass can return a default response instead. + """ try: data = response.json() @@ -1171,9 +1300,11 @@ def authorize_device( Args: **data: additional data to send to the Device Authorization Endpoint + requests_kwargs: additional parameters for `requests.request()` Returns: a Device Authorization Response + """ requests_kwargs = requests_kwargs or {} @@ -1186,34 +1317,35 @@ def authorize_device( **requests_kwargs, ) - def parse_device_authorization_response( - self, response: requests.Response - ) -> DeviceAuthorizationResponse: + def parse_device_authorization_response(self, response: requests.Response) -> DeviceAuthorizationResponse: """Parse a Device Authorization Response received by `authorize_device()`. - Invoked by [authorize_device()][requests_oauth2client.client.OAuth2Client.authorize_device] to parse the response returned by the Device Authorization Endpoint. + Invoked by [authorize_device()][requests_oauth2client.client.OAuth2Client.authorize_device] + to parse the response returned by the Device Authorization Endpoint. Args: response: the response returned by the Device Authorization Endpoint. Returns: a `DeviceAuthorizationResponse` as returned by AS + """ device_authorization_response = DeviceAuthorizationResponse(**response.json()) return device_authorization_response - def on_device_authorization_error( - self, response: requests.Response - ) -> DeviceAuthorizationResponse: + def on_device_authorization_error(self, response: requests.Response) -> DeviceAuthorizationResponse: """Error handler for `authorize_device()`. - Invoked by [authorize_device()][requests_oauth2client.client.OAuth2Client.authorize_device] to parse the response returned by the Device Authorization Endpoint, when that response is an error. + Invoked by [authorize_device()][requests_oauth2client.client.OAuth2Client.authorize_device] + to parse the response returned by the Device Authorization Endpoint, when that response is + an error. Args: response: the response returned by the Device Authorization Endpoint. Returns: usually raises an Exception. But a subclass may return a default response instead. + """ try: data = response.json() @@ -1226,19 +1358,18 @@ def on_device_authorization_error( raise InvalidDeviceAuthorizationResponse(response) from exc raise exception - def update_authorization_server_public_keys( - self, requests_kwargs: dict[str, Any] | None = None - ) -> JwkSet: + def update_authorization_server_public_keys(self, requests_kwargs: dict[str, Any] | None = None) -> JwkSet: """Update the cached AS public keys by retrieving them from its `jwks_uri`. - Public keys are returned by this method, as a [JwkSet][jwskate.JwkSet]. - They are also available in attribute `authorization_server_jwks`. + Public keys are returned by this method, as a `jwskate.JwkSet`. They are also + available in attribute `authorization_server_jwks`. Returns: the retrieved public keys Raises: ValueError: if no `jwks_uri` is configured + """ requests_kwargs = requests_kwargs or {} @@ -1250,7 +1381,7 @@ def update_authorization_server_public_keys( on_failure=lambda resp: resp.raise_for_status(), **requests_kwargs, ) - self.authorization_server_jwks = JwkSet(jwks) + self.authorization_server_jwks.update(jwks) return self.authorization_server_jwks @classmethod @@ -1267,9 +1398,9 @@ def from_discovery_endpoint( ) -> OAuth2Client: """Initialise an OAuth2Client based on Authorization Server Metadata. - This will retrieve the standardised metadata document available at `url`, and will extract all Endpoint Uris - from that document, will fetch the current public keys from its `jwks_uri`, then will initialize an OAuth2Client - based on those endpoints. + This will retrieve the standardised metadata document available at `url`, and will extract + all Endpoint Uris from that document, will fetch the current public keys from its + `jwks_uri`, then will initialise an OAuth2Client based on those endpoints. Args: url: the url where the server metadata will be retrieved @@ -1277,20 +1408,23 @@ def from_discovery_endpoint( client_id: client ID client_secret: client secret to use to authenticate the client private_key: private key to sign client assertions - session: a requests Session to use to retrieve the document and initialise the client with + session: a `requests.Session` to use to retrieve the document and initialise the client with issuer: if an issuer is given, check that it matches the one from the retrieved document + **kwargs: additional keyword parameters to pass to OAuth2Client Returns: - an OAuth2Client with endpoint initialized based on the obtained metadata + an OAuth2Client with endpoint initialised based on the obtained metadata Raises: - ValueError: if neither `url` or `issuer` are suitable urls. + ValueError: if neither `url` nor `issuer` are suitable urls requests.HTTPError: if an error happens while fetching the documents + """ if url is None and issuer is not None: url = oidc_discovery_document_url(issuer) if url is None: - raise ValueError("Please specify at least one of `issuer` or `url`") + msg = "Please specify at least one of `issuer` or `url`" + raise ValueError(msg) validate_endpoint_uri(url, path=False) @@ -1317,6 +1451,7 @@ def from_discovery_endpoint( def from_discovery_document( cls, discovery: dict[str, Any], + *, issuer: str | None = None, auth: requests.auth.AuthBase | tuple[str, str] | str | None = None, client_id: str | None = None, @@ -1339,13 +1474,16 @@ def from_discovery_document( authorization_server_jwks: the current authorization server JWKS keys session: a requests Session to use to retrieve the document and initialise the client with https: if True, validates that urls in the discovery document use the https scheme + **kwargs: additional args that will be passed to OAuth2Client Returns: an OAuth2Client + """ if issuer and discovery.get("issuer") != issuer: + msg = "Mismatching issuer value in discovery document: " raise ValueError( - "Mismatching issuer value in discovery document: ", + msg, issuer, discovery.get("issuer"), ) @@ -1354,7 +1492,8 @@ def from_discovery_document( token_endpoint = discovery.get("token_endpoint") if token_endpoint is None: - raise ValueError("token_endpoint not found in that discovery document") + msg = "token_endpoint not found in that discovery document" + raise ValueError(msg) validate_endpoint_uri(token_endpoint, https=https) authorization_endpoint = discovery.get("authorization_endpoint") if authorization_endpoint is not None: @@ -1398,6 +1537,7 @@ def __enter__(self) -> OAuth2Client: """Allow using OAuth2Client as a context-manager. The Authorization Server public keys are retrieved on __enter__. + """ self.update_authorization_server_public_keys() return self @@ -1409,9 +1549,23 @@ def _require_endpoint(self, endpoint: str) -> str: """Check that a required endpoint url is set.""" url = getattr(self, endpoint, None) if not url: - raise AttributeError( - f"No '{endpoint}' defined for this client. " - f"Please provide the URL for that endpoint when initializing your {self.__class__.__name__} instance." + msg = ( + f"No '{endpoint}' defined for this client. Please provide the URL for that" + f" endpoint when initializing your {self.__class__.__name__} instance." ) + raise AttributeError(msg) return str(url) + + +class GrantType(str, Enum): + """An enum of standardized `grant_type` values.""" + + CLIENT_CREDENTIALS = "client_credentials" + AUTHORIZATION_CODE = "authorization_code" + REFRESH_TOKEN = "refresh_token" + RESOURCE_OWNER_PASSWORD = "password" + TOKEN_EXCHANGE = "urn:ietf:params:oauth:grant-type:token-exchange" + JWT_BEARER = "urn:ietf:params:oauth:grant-type:jwt-bearer" + CLIENT_INITIATED_BACKCHANNEL_AUTHENTICATION = "urn:openid:params:grant-type:ciba" + DEVICE_CODE = "urn:ietf:params:oauth:grant-type:device_code" diff --git a/requests_oauth2client/client_authentication.py b/requests_oauth2client/client_authentication.py index 1a21f21..695eb47 100644 --- a/requests_oauth2client/client_authentication.py +++ b/requests_oauth2client/client_authentication.py @@ -1,19 +1,21 @@ -"""This modules implements OAuth 2.0 Client Authentication Methods. +"""This module implements OAuth 2.0 Client Authentication Methods. An OAuth 2.0 Client must authenticate to the AS whenever it sends a request to the Token Endpoint, by including appropriate credentials. This module contains helper classes and methods that implement -the standardised and commonly used Client Authentication Methods. +the standardized and commonly used Client Authentication Methods. + """ + from __future__ import annotations -from datetime import datetime +from datetime import datetime, timezone from typing import Any, Callable +from urllib.parse import parse_qs from uuid import uuid4 -import furl # type: ignore[import-not-found] import requests from binapy import BinaPy -from jwskate import Jwk, Jwt, SymmetricJwk +from jwskate import Jwk, Jwt, SignatureAlgs, SymmetricJwk class BaseClientAuthenticationMethod(requests.auth.AuthBase): @@ -21,6 +23,7 @@ class BaseClientAuthenticationMethod(requests.auth.AuthBase): This base class only checks that requests are suitable to add Client Authentication parameters to, and doesn't modify the request. + """ def __init__(self, client_id: str): @@ -30,6 +33,7 @@ def __call__(self, request: requests.PreparedRequest) -> requests.PreparedReques """Check that the request is suitable for Client Authentication. It checks: + * that the method is `POST` * that the Content-Type is "application/x-www-form-urlencoded" or None @@ -41,26 +45,27 @@ def __call__(self, request: requests.PreparedRequest) -> requests.PreparedReques Raises: RuntimeError: if the request is not suitable for OAuth 2.0 Client Authentication + """ if request.method != "POST" or request.headers.get("Content-Type") not in ( "application/x-www-form-urlencoded", None, ): - raise RuntimeError( - "This request is not suitable for OAuth 2.0 Client Authentication" - ) + msg = "This request is not suitable for OAuth 2.0 Client Authentication" + raise RuntimeError(msg) return request class ClientSecretBasic(BaseClientAuthenticationMethod): """Implement `client_secret_basic` authentication. - With this method, the client sends its Client ID and Secret, in the Authorization header, with the "Basic" scheme, - in each authenticated request to the AS. + With this method, the client sends its Client ID and Secret, in the Authorization header, with + the "Basic" scheme, in each authenticated request to the AS. Args: client_id: `client_id` to use. client_secret: `client_secret` to use. + """ def __init__(self, client_id: str, client_secret: str): @@ -70,19 +75,18 @@ def __init__(self, client_id: str, client_secret: str): def __call__(self, request: requests.PreparedRequest) -> requests.PreparedRequest: """Add the appropriate `Authorization` header in each request. - The Authorization header is formatted as such: - `Authorization: Basic BASE64('')` + The Authorization header is formatted as such: `Authorization: Basic + BASE64('')` Args: request: a [requests.PreparedRequest][]. Returns: a [requests.PreparedRequest][] with the added Authorization header. + """ request = super().__call__(request) - b64encoded_credentials = ( - BinaPy(f"{self.client_id}:{self.client_secret}").to("b64").ascii() - ) + b64encoded_credentials = BinaPy(f"{self.client_id}:{self.client_secret}").to("b64").ascii() request.headers["Authorization"] = f"Basic {b64encoded_credentials}" return request @@ -90,11 +94,13 @@ def __call__(self, request: requests.PreparedRequest) -> requests.PreparedReques class ClientSecretPost(BaseClientAuthenticationMethod): """Implement `client_secret_post` client authentication method. - With this method, the client inserts its client_id and client_secret in each authenticated request to the AS. + With this method, the client inserts its client_id and client_secret in each authenticated + request to the AS. Args: client_id: `client_id` to use. client_secret: `client_secret` to use. + """ def __init__(self, client_id: str, client_secret: str) -> None: @@ -109,16 +115,22 @@ def __call__(self, request: requests.PreparedRequest) -> requests.PreparedReques Returns: a [requests.PreparedRequest][] with the added client credentials fields. + """ request = super().__call__(request) - data = furl.Query(request.body) - data.set([("client_id", self.client_id), ("client_secret", self.client_secret)]) - request.prepare_body(data.params, files=None) + params = ( + parse_qs(request.body, strict_parsing=True, keep_blank_values=True) # type: ignore[type-var] + if isinstance(request.body, (str, bytes)) + else {} + ) + params[b"client_id"] = [self.client_id.encode()] + params[b"client_secret"] = [self.client_secret.encode()] + request.prepare_body(params, files=None) return request class ClientAssertionAuthenticationMethod(BaseClientAuthenticationMethod): - """Base class for assertion based client authentication methods. + """Base class for assertion-based client authentication methods. Args: client_id: the client_id to use @@ -126,6 +138,7 @@ class ClientAssertionAuthenticationMethod(BaseClientAuthenticationMethod): lifetime: the lifetime to use for generated Client Assertions. jti_gen: a function to generate JWT Token Ids (`jti`) for generated Client Assertions. aud: the audience value to use. If `None` (default), the endpoint URL will be used. + """ def __init__( @@ -150,6 +163,7 @@ def client_assertion(self, audience: str) -> str: Returns: a Client Assertion, as `str`. + """ raise NotImplementedError() # pragma: no cover @@ -161,31 +175,32 @@ def __call__(self, request: requests.PreparedRequest) -> requests.PreparedReques Returns: a [requests.PreparedRequest][] with the added `client_assertion` field. + """ request = super().__call__(request) audience = self.aud or request.url - assert audience is not None - data = furl.Query(request.body) - client_assertion = self.client_assertion(audience) - data.set( - [ - ("client_id", self.client_id), - ("client_assertion", client_assertion), - ( - "client_assertion_type", - "urn:ietf:params:oauth:client-assertion-type:jwt-bearer", - ), - ] + if audience is None: + msg = "No url defined for this request. This should never happen..." # pragma: no cover + raise ValueError(msg) # pragma: no cover + params = ( + parse_qs(request.body, strict_parsing=True, keep_blank_values=True) # type: ignore[type-var] + if request.body + else {} ) - request.prepare_body(data.params, files=None) + client_assertion = self.client_assertion(audience) + params[b"client_id"] = [self.client_id.encode()] + params[b"client_assertion"] = [client_assertion.encode()] + params[b"client_assertion_type"] = [b"urn:ietf:params:oauth:client-assertion-type:jwt-bearer"] + request.prepare_body(params, files=None) return request class ClientSecretJwt(ClientAssertionAuthenticationMethod): """Implement `client_secret_jwt` client authentication method. - With this method, client generates and signs a client assertion that is symmetrically signed with its Client Secret. - The assertion is then sent to the AS in a `client_assertion` field with each authenticated request. + With this method, the client generates and signs a client assertion that is symmetrically + signed with its Client Secret. The assertion is then sent to the AS in a `client_assertion` + field with each authenticated request. Args: client_id: the `client_id` to use. @@ -194,6 +209,7 @@ class ClientSecretJwt(ClientAssertionAuthenticationMethod): lifetime: the lifetime to use for generated Client Assertions. jti_gen: a function to generate JWT Token Ids (`jti`) for generated Client Assertions. aud: the audience value to use. If `None` (default), the endpoint URL will be used. + """ def __init__( @@ -218,8 +234,9 @@ def client_assertion(self, audience: str) -> str: Returns: a Client Assertion, as `str`. + """ - iat = int(datetime.now().timestamp()) + iat = int(datetime.now(tz=timezone.utc).timestamp()) exp = iat + self.lifetime jti = str(self.jti_gen()) @@ -243,8 +260,8 @@ def client_assertion(self, audience: str) -> str: class PrivateKeyJwt(ClientAssertionAuthenticationMethod): """Implement `private_key_jwt` client authentication method. - With this method, the client generates and sends a client_assertion, that is - asymmetrically signed with a private key, on each direct request to the Authorization Server. + With this method, the client generates and sends a client_assertion, that is asymmetrically + signed with a private key, on each direct request to the Authorization Server. Args: client_id: the `client_id` to use. @@ -253,13 +270,14 @@ class PrivateKeyJwt(ClientAssertionAuthenticationMethod): lifetime: the lifetime to use for generated Client Assertions. jti_gen: a function to generate JWT Token Ids (`jti`) for generated Client Assertions. aud: the audience value to use. If `None` (default), the endpoint URL will be used.k + """ def __init__( self, client_id: str, private_jwk: Jwk | dict[str, Any], - alg: str = "RS256", + alg: str = SignatureAlgs.RS256, lifetime: int = 60, jti_gen: Callable[[], Any] = lambda: uuid4(), aud: str | None = None, @@ -268,20 +286,17 @@ def __init__( private_jwk = Jwk(private_jwk) if not private_jwk.is_private or private_jwk.is_symmetric: - raise ValueError( - "Private Key JWT client authentication method uses asymmetric signing thus requires a private key." - ) + msg = "Private Key JWT client authentication method uses asymmetric signing thus requires a private key." + raise ValueError(msg) alg = private_jwk.alg or alg if not alg: - raise ValueError( - "An asymmetric signing alg is required, either as part of the private JWK, or passed as parameter." - ) + msg = "An asymmetric signing alg is required, either as part of the private JWK, or passed as parameter." + raise ValueError(msg) kid = private_jwk.get("kid") if not kid: - raise ValueError( - "Asymmetric signing requires the private JWK to have a Key ID (kid)." - ) + msg = "Asymmetric signing requires the private JWK to have a Key ID (kid)." + raise ValueError(msg) super().__init__(client_id, alg, lifetime, jti_gen, aud) self.private_jwk = private_jwk @@ -294,8 +309,9 @@ def client_assertion(self, audience: str) -> str: Returns: a Client Assertion. + """ - iat = int(datetime.now().timestamp()) + iat = int(datetime.now(tz=timezone.utc).timestamp()) exp = iat + self.lifetime jti = str(self.jti_gen()) @@ -322,6 +338,7 @@ class PublicApp(BaseClientAuthenticationMethod): Args: client_id: the client_id to use. + """ def __init__(self, client_id: str) -> None: @@ -335,30 +352,26 @@ def __call__(self, request: requests.PreparedRequest) -> requests.PreparedReques Returns: a [requests.PreparedRequest][] with the added `client_id` field. + """ request = super().__call__(request) - data = furl.Query(request.body) - data.set([("client_id", self.client_id)]) - request.prepare_body(data.params, files=None) + params = ( + parse_qs(request.body, strict_parsing=True, keep_blank_values=True) # type: ignore[type-var] + if request.body + else {} + ) + params[b"client_id"] = [self.client_id.encode()] + request.prepare_body(params, files=None) return request def client_auth_factory( - auth: ( - requests.auth.AuthBase - | tuple[str, str] - | tuple[str, Jwk] - | tuple[str, dict[str, Any]] - | str - | None - ), + auth: requests.auth.AuthBase | tuple[str, str] | tuple[str, Jwk] | tuple[str, dict[str, Any]] | str | None, *, client_id: str | None = None, client_secret: str | None = None, private_key: Jwk | dict[str, Any] | None = None, - default_auth_handler: ( - type[ClientSecretPost] | type[ClientSecretBasic] | type[ClientSecretJwt] - ) = ClientSecretPost, + default_auth_handler: type[ClientSecretPost] | type[ClientSecretBasic] | type[ClientSecretJwt] = ClientSecretPost, ) -> requests.auth.AuthBase: """Initialize the appropriate Auth Handler based on the provided parameters. @@ -366,44 +379,52 @@ def client_auth_factory( Args: auth: can be: + - a `requests.auth.AuthBase` instance (which will be used directly) - - a tuple of (client_id, client_secret) which will be used to initialize an instance of `default_auth_handler`, - - a tuple of (client_id, jwk), used to initialize a `PrivateKeyJwk` (`jwk` being an instance of `jwskate.Jwk` or a `dict`), + - a tuple of (client_id, client_secret) which will be used to initialize an instance of + `default_auth_handler`, + - a tuple of (client_id, jwk), used to initialize a `PrivateKeyJwk` (`jwk` being an + instance of `jwskate.Jwk` or a `dict`), - a `client_id`, as `str`, - - or `None`, to pass `client_id` and other credentials as dedicated parameters, see below. + - or `None`, to pass `client_id` and other credentials as dedicated parameters, see + below. client_id: the Client ID to use for this client - client_secret: the Client Secret to use for this client, if any (for clients using an authentication method based on a secret) + client_secret: the Client Secret to use for this client, if any (for clients using + an authentication method based on a secret) private_key: the private key to use for private_key_jwt authentication method - default_auth_handler: if a client_id and client_secret are provided, initialize an instance of this class with those 2 parameters. + default_auth_handler: if a client_id and client_secret are provided, initialize an + instance of this class with those 2 parameters. You can choose between `ClientSecretBasic`, `ClientSecretPost`, or `ClientSecretJwt`. Returns: - an Auth Handler that will manage client authentication to the AS Token Endpoint or other backend endpoints. + an Auth Handler that will manage client authentication to the AS Token Endpoint or other + backend endpoints. + """ - if auth is not None and ( - client_id is not None or client_secret is not None or private_key is not None - ): - raise ValueError( - "Please use either `auth` parameter to provide an authentication method, or use `client_id` and one of `client_secret` or `private_key`." + if auth is not None and (client_id is not None or client_secret is not None or private_key is not None): + msg = ( + "Please use either `auth` parameter to provide an authentication method, or use" + " `client_id` and one of `client_secret` or `private_key`." ) + raise ValueError(msg) if isinstance(auth, str): client_id = auth elif isinstance(auth, requests.auth.AuthBase): return auth - elif isinstance(auth, tuple) and len(auth) == 2: + elif isinstance(auth, tuple) and len(auth) == 2: # noqa: PLR2004 client_id, credential = auth if isinstance(credential, (Jwk, dict)): private_key = credential elif isinstance(credential, str): client_secret = credential else: - raise TypeError( - "This credential type is not supported:", type(credential), credential - ) + msg = "This credential type is not supported:" + raise TypeError(msg, type(credential), credential) if client_id is None: - raise ValueError("A client_id must be provided.") + msg = "A client_id must be provided." + raise ValueError(msg) if private_key is not None: return PrivateKeyJwt(str(client_id), private_key) diff --git a/requests_oauth2client/device_authorization.py b/requests_oauth2client/device_authorization.py index 80fd8c1..5f3d78a 100644 --- a/requests_oauth2client/device_authorization.py +++ b/requests_oauth2client/device_authorization.py @@ -1,10 +1,12 @@ """Implements the Device Authorization Flow as defined in RFC8628. See [RFC8628](https://datatracker.ietf.org/doc/html/rfc8628). + """ + from __future__ import annotations -from datetime import datetime, timedelta +from datetime import datetime, timedelta, timezone from typing import TYPE_CHECKING, Any from .pooling import TokenEndpointPoolingJob @@ -25,9 +27,11 @@ class DeviceAuthorizationResponse: user_code: the `device_code` as returned by the AS. verification_uri: the `device_code` as returned by the AS. verification_uri_complete: the `device_code` as returned by the AS. - expires_at: the expiration date for the device_code. Also accepts an `expires_in` parameter, as a number of seconds in the future. + expires_at: the expiration date for the device_code. + Also accepts an `expires_in` parameter, as a number of seconds in the future. interval: the pooling `interval` as returned by the AS. **kwargs: additional parameters as returned by the AS. + """ @accepts_expires_in @@ -53,38 +57,37 @@ def is_expired(self, leeway: int = 0) -> bool | None: """Check if the `device_code` within this response is expired. Returns: - `True` if the device_code is expired, `False` if it is still valid, `None` if there is no `expires_in` hint. + `True` if the device_code is expired, `False` if it is still valid, `None` if there is + no `expires_in` hint. + """ if self.expires_at: - return datetime.now() - timedelta(seconds=leeway) > self.expires_at + return datetime.now(tz=timezone.utc) - timedelta(seconds=leeway) > self.expires_at return None class DeviceAuthorizationPoolingJob(TokenEndpointPoolingJob): """A Token Endpoint pooling job for the Device Authorization Flow. - This periodically checks if the user has finished with his authorization in a - Device Authorization flow. + This periodically checks if the user has finished with his authorization in a Device + Authorization flow. Args: client: an OAuth2Client that will be used to pool the token endpoint. device_code: a `device_code` as `str` or a `DeviceAuthorizationResponse`. - interval: The pooling interval to use. This overrides the one in `auth_req_id` if it is a `BackChannelAuthenticationResponse`. - slow_down_interval: Number of seconds to add to the pooling interval when the AS returns a slow down request. + interval: The pooling interval to use. This overrides the one in `auth_req_id` if it is + a `BackChannelAuthenticationResponse`. + slow_down_interval: Number of seconds to add to the pooling interval when the AS returns + a slow-down request. requests_kwargs: Additional parameters for the underlying calls to [requests.request][]. **token_kwargs: Additional parameters for the token request. - Usage: - ```python - client = OAuth2Client( - token_endpoint="https://my.as.local/token", auth=("client_id", "client_secret") - ) - pool_job = DeviceAuthorizationPoolingJob(client=client, device_code="my_device_code") + Usage: ```python client = OAuth2Client( token_endpoint="https://my.as.local/token", + auth=("client_id", "client_secret") ) pool_job = DeviceAuthorizationPoolingJob(client=client, + device_code="my_device_code") + + token = None while token is None: token = pool_job() ``` - token = None - while token is None: - token = pool_job() - ``` """ def __init__( @@ -112,7 +115,6 @@ def token_request(self) -> BearerToken: Returns: a [BearerToken][requests_oauth2client.tokens.BearerToken] + """ - return self.client.device_code( - self.device_code, requests_kwargs=self.requests_kwargs, **self.token_kwargs - ) + return self.client.device_code(self.device_code, requests_kwargs=self.requests_kwargs, **self.token_kwargs) diff --git a/requests_oauth2client/discovery.py b/requests_oauth2client/discovery.py index e272ed2..ba65066 100644 --- a/requests_oauth2client/discovery.py +++ b/requests_oauth2client/discovery.py @@ -1,25 +1,31 @@ """Implements Metadata discovery documents URLS. -This is as defined in [RFC8615](https://datatracker.ietf.org/doc/html/rfc8615) -and [OpenID Connect Discovery 1.0](https://openid.net/specs/openid-connect-discovery-1_0.html#ProviderMetadata). +This is as defined in [RFC8615](https://datatracker.ietf.org/doc/html/rfc8615) and [OpenID Connect +Discovery 1.0](https://openid.net/specs/openid-connect-discovery-1_0.html#ProviderMetadata). + """ -from furl import Path, furl # type: ignore[import-not-found] +from furl import Path, furl # type: ignore[import-untyped] -def well_known_uri(origin: str, name: str, at_root: bool = True) -> str: +def well_known_uri(origin: str, name: str, *, at_root: bool = True) -> str: """Return the location of a well-known document on an origin url. - See [RFC8615](https://datatracker.ietf.org/doc/html/rfc8615) and [OIDC Discovery](https://openid.net/specs/openid-connect-discovery-1_0.html#ProviderMetadata). + See [RFC8615](https://datatracker.ietf.org/doc/html/rfc8615) and [OIDC + Discovery](https://openid.net/specs/openid-connect-discovery-1_0.html#ProviderMetadata). Args: origin: origin to use to build the well-known uri. name: document name to use to build the well-known uri. at_root: if `True`, assume the well-known document is at root level (as defined in [RFC8615](https://datatracker.ietf.org/doc/html/rfc8615)). - If `False`, assume the well-known location is per-directory, as defined in [OpenID Connect Discovery 1.0](https://openid.net/specs/openid-connect-discovery-1_0.html#ProviderMetadata). + If `False`, assume the well-known location is per-directory, as defined in [OpenID + Connect Discovery + 1.0](https://openid.net/specs/openid-connect-discovery-1_0.html#ProviderMetadata). Returns: - the well-know uri, relative to origin, where the well-known document named `name` should be found. + the well-know uri, relative to origin, where the well-known document named `name` should be + found. + """ url = furl(origin) if at_root: @@ -32,16 +38,19 @@ def well_known_uri(origin: str, name: str, at_root: bool = True) -> str: def oidc_discovery_document_url(issuer: str) -> str: """Construct the OIDC discovery document url for a given `issuer`. - Given an `issuer` identifier, return the standardised URL where the OIDC - discovery document can be retrieved. + Given an `issuer` identifier, return the standardised URL where the OIDC discovery document can + be retrieved. - The returned URL is biuilt as specified in [OpenID Connect Discovery 1.0](https://openid.net/specs/openid-connect-discovery-1_0.html#ProviderMetadata). + The returned URL is biuilt as specified in [OpenID Connect Discovery + 1.0](https://openid.net/specs/openid-connect-discovery-1_0.html#ProviderMetadata). Args: issuer: an OIDC Authentication Server `issuer` Returns: - the standardised discovery document URL. Note that no attempt to fetch this document is made. + the standardised discovery document URL. Note that no attempt to fetch this document is + made. + """ return well_known_uri(issuer, "openid-configuration", at_root=False) @@ -49,15 +58,18 @@ def oidc_discovery_document_url(issuer: str) -> str: def oauth2_discovery_document_url(issuer: str) -> str: """Construct the standardised OAuth 2.0 discovery document url for a given `issuer`. - Based an `issuer` identifier, returns the standardised URL where the OAuth20 - server metadata can be retrieved. + Based an `issuer` identifier, returns the standardised URL where the OAuth20 server metadata can + be retrieved. - The returned URL is built as specified in [RFC8414](https://datatracker.ietf.org/doc/html/rfc8414). + The returned URL is built as specified in + [RFC8414](https://datatracker.ietf.org/doc/html/rfc8414). Args: issuer: an OAuth20 Authentication Server `issuer` Returns: - the standardised discovery document URL. Note that no attempt to fetch this document is made. + the standardised discovery document URL. Note that no attempt to fetch this document is + made. + """ return well_known_uri(issuer, "oauth-authorization-server", at_root=True) diff --git a/requests_oauth2client/exceptions.py b/requests_oauth2client/exceptions.py index 0788305..bc06f72 100644 --- a/requests_oauth2client/exceptions.py +++ b/requests_oauth2client/exceptions.py @@ -1,4 +1,5 @@ """This module contains all exception classes from `requests_oauth2client`.""" + from __future__ import annotations from typing import TYPE_CHECKING @@ -14,6 +15,7 @@ class OAuth2Error(Exception): Args: response: the HTTP response containing the error + """ def __init__(self, response: requests.Response): @@ -28,13 +30,15 @@ def request(self) -> requests.PreparedRequest: class EndpointError(OAuth2Error): """Base class for exceptions raised from backend endpoint errors. - This contains the error message, description and uri that are returned by the AS in the OAuth 2.0 standardised way. + This contains the error message, description and uri that are returned by the AS in the OAuth + 2.0 standardised way. Args: response: the raw requests.PreparedResponse containing the error. error: the `error` identifier as returned by the AS. description: the `error_description` as returned by the AS. uri: the `error_uri` as returned by the AS. + """ def __init__( @@ -135,18 +139,20 @@ class InvalidDeviceAuthorizationResponse(OAuth2Error): class InvalidIdToken(InvalidJwt): - """Raised when trying to validate an invalid Id Token value.""" + """Raised when trying to validate an invalid ID Token value.""" class AuthorizationResponseError(Exception): """Base class for error responses returned by the Authorization endpoint. - An `AuthorizationResponseError` contains the error message, description and uri that are returned by the AS. + An `AuthorizationResponseError` contains the error message, description and uri that are + returned by the AS. Args: error: the `error` identifier as returned by the AS description: the `error_description` as returned by the AS uri: the `error_uri` as returned by the AS + """ def __init__(self, error: str, description: str | None = None, uri: str | None = None): @@ -184,6 +190,7 @@ class MissingAuthCode(InvalidAuthResponse): This happens when the Authorization Endpoint does not return an error, but does not return an authorization `code` either. + """ @@ -194,6 +201,7 @@ class MissingIssuer(InvalidAuthResponse): `authorization_response_iss_parameter_supported` in its discovery document. If it is set to `true`, it must include an `iss` parameter in its authorization responses, containing its issuer identifier. + """ @@ -202,6 +210,7 @@ class MissingIdToken(InvalidAuthResponse): This happens when the Authorization Endpoint does not return an error, but does not return an ID Token either. + """ @@ -210,6 +219,7 @@ class MismatchingState(InvalidAuthResponse): This happens when the Authorization Endpoints returns a 'state' parameter that doesn't match the value passed in the Authorization Request. + """ @@ -218,6 +228,7 @@ class MismatchingIssuer(InvalidAuthResponse): This happens when the Authorization Endpoints returns an 'iss' that doesn't match the expected value. + """ @@ -226,6 +237,7 @@ class MismatchingNonce(InvalidIdToken): This happens when the authorization request includes a `nonce` but the returned ID Token include a different value. + """ @@ -234,6 +246,7 @@ class MismatchingAcr(InvalidIdToken): This happens when the authorization request includes an `acr_values` parameter but the returned ID Token includes a different value. + """ diff --git a/requests_oauth2client/flask/__init__.py b/requests_oauth2client/flask/__init__.py index 7e4fc83..8b023d0 100644 --- a/requests_oauth2client/flask/__init__.py +++ b/requests_oauth2client/flask/__init__.py @@ -1,6 +1,9 @@ -"""This modules contains helper classes for the Flask Framework. +"""This module contains helper classes for the Flask Framework. See [Flask framework](https://flask.palletsprojects.com). + """ from .auth import FlaskOAuth2ClientCredentialsAuth + +__all__ = ["FlaskOAuth2ClientCredentialsAuth"] diff --git a/requests_oauth2client/flask/auth.py b/requests_oauth2client/flask/auth.py index ec011e2..6417000 100644 --- a/requests_oauth2client/flask/auth.py +++ b/requests_oauth2client/flask/auth.py @@ -1,23 +1,26 @@ """Helper classes for the [Flask](https://flask.palletsprojects.com) framework.""" + from __future__ import annotations from typing import Any from flask import session -from ..auth import OAuth2ClientCredentialsAuth -from ..tokens import BearerToken, BearerTokenSerializer +from requests_oauth2client.auth import OAuth2ClientCredentialsAuth +from requests_oauth2client.tokens import BearerToken, BearerTokenSerializer class FlaskSessionAuthMixin: """A Mixin for auth handlers to store their tokens in Flask session. - Storing tokens in Flask session does ensure that each user of a Flask application has a different access token, and - that tokens used for backend API access will be persisted between multiple requests to the front-end Flask app. + Storing tokens in Flask session does ensure that each user of a Flask application has a + different access token, and that tokens used for backend API access will be persisted between + multiple requests to the front-end Flask app. Args: session_key: the key that will be used to store the access token in session. serializer: the serializer that will be used to store the access token in session. + """ def __init__( @@ -25,9 +28,9 @@ def __init__( session_key: str, serializer: BearerTokenSerializer | None = None, *args: Any, - **kwargs: Any, + **token_kwargs: Any, ) -> None: - super().__init__(*args, **kwargs) + super().__init__(*args, **token_kwargs) self.serializer = serializer or BearerTokenSerializer() self.session_key = session_key @@ -37,6 +40,7 @@ def token(self) -> BearerToken | None: Returns: The current `BearerToken` for this session, if any. + """ serialized_token = session.get(self.session_key) if serialized_token is None: @@ -49,6 +53,7 @@ def token(self, token: BearerToken | str | None) -> None: Args: token: the token to store + """ if isinstance(token, str): token = BearerToken(token) # pragma: no cover @@ -62,13 +67,8 @@ def token(self, token: BearerToken | str | None) -> None: class FlaskOAuth2ClientCredentialsAuth(FlaskSessionAuthMixin, OAuth2ClientCredentialsAuth): """A `requests` Auth handler for CC grant that stores its token in Flask session. - It will automatically get Access Tokens from an OAuth 2.x AS - with the Client Credentials grant (and can get a new one once the first one is expired), - and stores the retrieved token, serialized in Flask `session`, so that each user has a different access token. + It will automatically get Access Tokens from an OAuth 2.x AS with the Client Credentials grant + (and can get a new one once the first one is expired), and stores the retrieved token, + serialized in Flask `session`, so that each user has a different access token. - Args: - client: an OAuth2Client that will be used to retrieve tokens. - session_key: the key that will be used to store the access token in Flask session - serializer: a serializer that will be used to serialize the access token in Flask session - **token_kwargs: additional kwargs for the Token Request """ diff --git a/requests_oauth2client/pooling.py b/requests_oauth2client/pooling.py index b19b4e1..f764daf 100644 --- a/requests_oauth2client/pooling.py +++ b/requests_oauth2client/pooling.py @@ -1,4 +1,5 @@ """Contains base classes for pooling jobs.""" + from __future__ import annotations import time @@ -17,16 +18,19 @@ class TokenEndpointPoolingJob(ABC): This is used for decoupled flows like CIBA or Device Authorization. - This class must be subclassed to implement actual BackChannel flows. - This needs an [OAuth2Client][requests_oauth2client.client.OAuth2Client] that will be used to pool the token + This class must be subclassed to implement actual BackChannel flows. This needs an + [OAuth2Client][requests_oauth2client.client.OAuth2Client] that will be used to pool the token endpoint. The initial pooling `interval` is configurable. Args: - client: the [OAuth2Client][requests_oauth2client.client.OAuth2Client] that will be used to pool the token endpoint. + client: the [OAuth2Client][requests_oauth2client.client.OAuth2Client] that will be used + to pool the token endpoint. interval: initial pooling interval, in seconds. If `None`, default to `5`. - slow_down_interval: when a [SlowDown][requests_oauth2client.exceptions.SlowDown] is received, this number of seconds will be added to the pooling interval. + slow_down_interval: when a [SlowDown][requests_oauth2client.exceptions.SlowDown] is + received, this number of seconds will be added to the pooling interval. requests_kwargs: additional parameters for the underlying calls to [requests.request][] **token_kwargs: additional parameters for the token request + """ def __init__( @@ -46,15 +50,20 @@ def __init__( def __call__(self) -> BearerToken | None: """Wrap the actual Token Endpoint call with a pooling interval. - Everytime this method is called, it will wait for the entire duration of the pooling interval before calling - [token_request()][requests_oauth2client.pooling.TokenEndpointPoolingJob.token_request]. So you can call it - immediately after initiating the BackChannel flow, and it will wait before initiating the first call. + Everytime this method is called, it will wait for the entire duration of the pooling + interval before calling + [token_request()][requests_oauth2client.pooling.TokenEndpointPoolingJob.token_request]. So + you can call it immediately after initiating the BackChannel flow, and it will wait before + initiating the first call. - This implements the logic to handle [AuthorizationPending][requests_oauth2client.exceptions.AuthorizationPending] - or [SlowDown][requests_oauth2client.exceptions.SlowDown] requests by the AS. + This implements the logic to handle + [AuthorizationPending][requests_oauth2client.exceptions.AuthorizationPending] or + [SlowDown][requests_oauth2client.exceptions.SlowDown] requests by the AS. Returns: - a [BearerToken][requests_oauth2client.tokens.BearerToken] if the AS returns one, or `None` if the Authorization is still pending. + a [BearerToken][requests_oauth2client.tokens.BearerToken] if the AS returns one, or + `None` if the Authorization is still pending. + """ time.sleep(self.interval) try: @@ -69,11 +78,13 @@ def __call__(self) -> BearerToken | None: def token_request(self) -> BearerToken: """Abstract method for the token endpoint call. - This must be implemented by subclasses. This method must - Must raise [AuthorizationPending][requests_oauth2client.exceptions.AuthorizationPending] to retry after the pooling interval, - or [SlowDown][requests_oauth2client.exceptions.SlowDown] to increase the pooling interval by `slow_down_interval` seconds. + This must be implemented by subclasses. This method must Must raise + [AuthorizationPending][requests_oauth2client.exceptions.AuthorizationPending] to retry after + the pooling interval, or [SlowDown][requests_oauth2client.exceptions.SlowDown] to increase + the pooling interval by `slow_down_interval` seconds. Returns: a [BearerToken][requests_oauth2client.tokens.BearerToken] + """ raise NotImplementedError # pragma: no cover diff --git a/requests_oauth2client/tokens.py b/requests_oauth2client/tokens.py index 9a1f4cf..dd743bc 100644 --- a/requests_oauth2client/tokens.py +++ b/requests_oauth2client/tokens.py @@ -1,12 +1,15 @@ -"""This module contain classes that represent Tokens used in OAuth2.0 / OIDC.""" +"""This module contains classes that represent Tokens used in OAuth2.0 / OIDC.""" + from __future__ import annotations -import pprint from datetime import datetime, timedelta, timezone -from typing import TYPE_CHECKING, Any, Callable +from enum import Enum +from typing import TYPE_CHECKING, Any, Callable, ClassVar import jwskate +from attrs import Factory, asdict, frozen from binapy import BinaPy +from typing_extensions import Self from .exceptions import ( ExpiredIdToken, @@ -26,11 +29,26 @@ from .client import OAuth2Client +class TokenType(str, Enum): + """An enum of standardised `token_type` values.""" + + ACCESS_TOKEN = "access_token" + REFRESH_TOKEN = "refresh_token" + ID_TOKEN = "id_token" + + +class AccessTokenType(str, Enum): + """An enum of standardised `access_token` types.""" + + BEARER = "Bearer" + + class IdToken(jwskate.SignedJwt): """Represent an ID Token. An ID Token is actually a Signed JWT. If the ID Token is encrypted, it must be decoded beforehand. + """ @property @@ -39,29 +57,81 @@ def auth_time(self) -> datetime: auth_time = self.claims.get("auth_time") if auth_time: return self.timestamp_to_datetime(auth_time) - raise AttributeError("This ID Token doesn't have an `auth_time` attribute.") + msg = "This ID Token doesn't have an `auth_time` attribute." + raise AttributeError(msg) + + @classmethod + def hash_method(cls, key: jwskate.Jwk, alg: str | None = None) -> Callable[[str], str]: + """Returns a callable that generates valid OIDC hashes, such as at_hash, c_hash, s_hash. + + Args: + key: the ID token signature verification public key + alg: the ID token signature algorithm + + Returns: + a callable that takes a string as input and produces a valid hash as a str output + + """ + alg_class = jwskate.select_alg_class(key.SIGNATURE_ALGORITHMS, jwk_alg=key.alg, alg=alg) + if alg_class == jwskate.EdDsa: + if key.crv == "Ed25519": + + def hash_method(token: str) -> str: + return BinaPy(token).to("sha512")[:32].to("b64u").decode() + + elif key.crv == "Ed448": + + def hash_method(token: str) -> str: + return BinaPy(token).to("shake256", 456).to("b64u").decode() + + else: + hash_alg = alg_class.hashing_alg.name + hash_size = alg_class.hashing_alg.digest_size + def hash_method(token: str) -> str: + return BinaPy(token).to(hash_alg)[: hash_size // 2].to("b64u").decode() -class BearerToken: + return hash_method + + +class AccessToken: + """Base class for Access Tokens.""" + + TOKEN_TYPE: ClassVar[str] + + +@frozen(init=False) +class BearerToken(AccessToken): """Represents a Bearer Token as returned by a Token Endpoint. - This is a wrapper around a Bearer Token and associated parameters, - such as expiration date and refresh token, as returned by an OAuth 2.x or OIDC 1.0 Token Endpoint. + This is a wrapper around a Bearer Token and associated parameters, such as expiration date and + refresh token, as returned by an OAuth 2.x or OIDC 1.0 Token Endpoint. - All parameters are as returned by a Token Endpoint. The token expiration date can be passed as datetime - in the `expires_at` parameter, or an `expires_in` parameter, as number of seconds in the future, can be passed instead. + All parameters are as returned by a Token Endpoint. The token expiration date can be passed as + datetime in the `expires_at` parameter, or an `expires_in` parameter, as number of seconds in + the future, can be passed instead. Args: access_token: an `access_token`, as returned by the AS. - expires_at: an expiration date. This method also accepts an `expires_in` hint as returned by the AS, if any. + expires_at: an expiration date. This method also accepts an `expires_in` hint as + returned by the AS, if any. scope: a `scope`, as returned by the AS, if any. refresh_token: a `refresh_token`, as returned by the AS, if any. token_type: a `token_type`, as returned by the AS. id_token: an `id_token`, as returned by the AS, if any. **kwargs: additional parameters as returned by the AS, if any. + """ - TOKEN_TYPE = "Bearer" + TOKEN_TYPE: ClassVar[str] = AccessTokenType.BEARER.value + + access_token: str + expires_at: datetime | None = None + scope: str | None = None + refresh_token: str | None = None + token_type: str = TOKEN_TYPE + id_token: IdToken | jwskate.JweCompact | None = None + kwargs: dict[str, Any] = Factory(dict) @accepts_expires_in def __init__( @@ -72,39 +142,51 @@ def __init__( scope: str | None = None, refresh_token: str | None = None, token_type: str = TOKEN_TYPE, - id_token: str | None = None, + id_token: str | bytes | IdToken | jwskate.JweCompact | None = None, **kwargs: Any, ): if token_type.title() != self.TOKEN_TYPE.title(): - raise ValueError(f"Token Type is not '{self.TOKEN_TYPE}'!", token_type) - self.access_token = access_token - self.expires_at = expires_at - self.scope = scope - self.refresh_token = refresh_token - self.id_token: IdToken | jwskate.JweCompact | None = None - if id_token: + msg = f"Token Type is not '{self.TOKEN_TYPE}'!" + raise ValueError(msg, token_type) + id_token_jwt: IdToken | jwskate.JweCompact | None = None + if isinstance(id_token, (str, bytes)): try: - self.id_token = IdToken(id_token) + id_token_jwt = IdToken(id_token) except jwskate.InvalidJwt: try: - self.id_token = jwskate.JweCompact(id_token) + id_token_jwt = jwskate.JweCompact(id_token) except jwskate.InvalidJwe: - raise InvalidIdToken( - "ID Token is invalid because it is neither a JWT or a JWE." - ) - self.other = kwargs + msg = "ID Token is invalid because it is neither a JWT or a JWE." + raise InvalidIdToken(msg) from None + else: + id_token_jwt = id_token + self.__attrs_init__( + access_token=access_token, + expires_at=expires_at, + scope=scope, + refresh_token=refresh_token, + token_type=token_type, + id_token=id_token_jwt, + kwargs=kwargs, + ) def is_expired(self, leeway: int = 0) -> bool | None: """Check if the access token is expired. Args: - leeway: If the token expires in the next given number of seconds, then consider it expired already. + leeway: If the token expires in the next given number of seconds, + then consider it expired already. Returns: - `True` if the access token is expired, `False` if it is still valid, `None` if there is no expires_in hint. + One of: + + - `True` if the access token is expired + - `False` if it is still valid + - `None` if there is no expires_in hint. + """ if self.expires_at: - return datetime.now() + timedelta(seconds=leeway) > self.expires_at + return datetime.now(tz=timezone.utc) + timedelta(seconds=leeway) > self.expires_at return None def authorization_header(self) -> str: @@ -113,63 +195,56 @@ def authorization_header(self) -> str: The value is formatted correctly according to RFC6750. Returns: - the value to use in a HTTP Authorization Header + the value to use in an HTTP Authorization Header + """ return f"Bearer {self.access_token}" - def validate_id_token( # noqa: C901 - self, client: OAuth2Client, azr: AuthorizationResponse - ) -> IdToken: + def validate_id_token(self, client: OAuth2Client, azr: AuthorizationResponse) -> Self: # noqa: C901, PLR0915 """Validate that a token response is valid, and return the ID Token. - This will validate the id_token as described - in [OIDC 1.0 $3.1.3.7](https://openid.net/specs/openid-connect-core-1_0.html#IDTokenValidation). + This will validate the id_token as described in [OIDC 1.0 + $3.1.3.7](https://openid.net/specs/openid-connect-core-1_0.html#IDTokenValidation). + + If the ID Token is encrypted, this decrypts it and returns the clear-text ID Token. - If the ID Token was encrypted, this decrypts it and returns the clear-text ID Token. """ if not self.id_token: raise MissingIdToken() raw_id_token = self.id_token - if ( - isinstance(raw_id_token, jwskate.JweCompact) - and client.id_token_encrypted_response_alg is None - ): - raise InvalidIdToken("ID Token is encrypted while it should be clear-text", self) - elif ( - isinstance(raw_id_token, IdToken) - and client.id_token_encrypted_response_alg is not None - ): - raise InvalidIdToken("ID Token is clear-text while it should be encrypted", self) + if isinstance(raw_id_token, jwskate.JweCompact) and client.id_token_encrypted_response_alg is None: + msg = "ID Token is encrypted while it should be clear-text" + raise InvalidIdToken(msg, self) + elif isinstance(raw_id_token, IdToken) and client.id_token_encrypted_response_alg is not None: + msg = "ID Token is clear-text while it should be encrypted" + raise InvalidIdToken(msg, self) if isinstance(raw_id_token, jwskate.JweCompact): enc_jwk = client.id_token_decryption_key if enc_jwk is None: - raise InvalidIdToken( - "ID Token is encrypted but client does not have a decryption key", self - ) + msg = "ID Token is encrypted but client does not have a decryption key" + raise InvalidIdToken(msg, self) nested_id_token = raw_id_token.decrypt(enc_jwk) id_token = IdToken(nested_id_token) else: id_token = raw_id_token if id_token.get_header("alg") is None and client.id_token_signed_response_alg is None: - raise InvalidIdToken( - "ID Token does not contain an `alg` parameter to specify the signature algorithm, " - "and no algorithm has been configured for the client (using param id_token_signed_response_alg`." + msg = ( + "ID Token does not contain an `alg` parameter to specify the signature" + " algorithm, and no algorithm has been configured for the client (using param" + " id_token_signed_response_alg`." ) - elif ( - client.id_token_signed_response_alg is not None - and id_token.alg != client.id_token_signed_response_alg - ): + raise InvalidIdToken(msg) + elif client.id_token_signed_response_alg is not None and id_token.alg != client.id_token_signed_response_alg: raise MismatchingIdTokenAlg(id_token.alg, client.id_token_signed_response_alg) id_token_alg = id_token.alg or client.id_token_signed_response_alg - if azr.issuer: - if id_token.issuer != azr.issuer: - raise MismatchingIssuer(id_token.issuer, azr.issuer, self) + if azr.issuer and id_token.issuer != azr.issuer: + raise MismatchingIssuer(id_token.issuer, azr.issuer, self) if id_token.audiences and client.client_id not in id_token.audiences: raise MismatchingAudience(id_token.audiences, client.client_id, self) @@ -180,155 +255,131 @@ def validate_id_token( # noqa: C901 if id_token.is_expired(): raise ExpiredIdToken(id_token) - if azr.nonce: - if id_token.nonce != azr.nonce: - raise MismatchingNonce() + if azr.nonce and id_token.nonce != azr.nonce: + raise MismatchingNonce() - if azr.acr_values: - if id_token.acr not in azr.acr_values: - raise MismatchingAcr(id_token.acr, azr.acr_values) + if azr.acr_values and id_token.acr not in azr.acr_values: + raise MismatchingAcr(id_token.acr, azr.acr_values) hash_function: Callable[[str], str] # method used to calculate at_hash, s_hash, etc. if id_token_alg in jwskate.SignatureAlgs.ALL_SYMMETRIC: if not client.client_secret: - raise InvalidIdToken( - "ID Token is symmetrically signed but this client does not have a Client Secret." - ) - id_token.verify_signature( - jwskate.SymmetricJwk.from_bytes(client.client_secret), alg=id_token_alg - ) + msg = "ID Token is symmetrically signed but this client does not have a Client Secret." + raise InvalidIdToken(msg) + id_token.verify_signature(jwskate.SymmetricJwk.from_bytes(client.client_secret), alg=id_token_alg) elif id_token_alg in jwskate.SignatureAlgs.ALL_ASYMMETRIC: if not client.authorization_server_jwks: - raise InvalidIdToken( - "ID Token is asymmetrically signed but the Authorization Server JWKS is not available." - ) + msg = "ID Token is asymmetrically signed but the Authorization Server JWKS is not available." + raise InvalidIdToken(msg) if id_token.get_header("kid") is None: - raise InvalidIdToken( - "ID Token does not contain a Key ID (kid) to specify the asymmetric key to use for signature verification." + msg = ( + "ID Token does not contain a Key ID (kid) to specify the asymmetric key " + "to use for signature verification." ) + raise InvalidIdToken(msg) try: verification_jwk = client.authorization_server_jwks.get_jwk_by_kid(id_token.kid) except KeyError: - raise InvalidIdToken( - "ID Token is asymmetrically signed but its Key ID is not part of the Authorization Server JWKS." + msg = ( + f"ID Token is asymmetrically signed but its Key ID '{id_token.kid}' " + "is not part of the Authorization Server JWKS." ) + raise InvalidIdToken(msg) from None if id_token_alg not in verification_jwk.supported_signing_algorithms(): - raise InvalidIdToken( - "ID Token is asymmetrically signed but its algorithm is not supported by the verification key." - ) + msg = "ID Token is asymmetrically signed but its algorithm is not supported by the verification key." + raise InvalidIdToken(msg) id_token.verify_signature(verification_jwk, alg=id_token_alg) - alg_class = jwskate.select_alg_class( - verification_jwk.SIGNATURE_ALGORITHMS, jwk_alg=id_token_alg - ) - if alg_class == jwskate.EdDsa: - if verification_jwk.crv == "Ed25519": - hash_function = ( - lambda token: BinaPy(token).to("sha512")[:32].to("b64u").ascii() - ) - elif verification_jwk.crv == "Ed448": - hash_function = ( - lambda token: BinaPy(token).to("shake256", 456).to("b64u").ascii() - ) - else: - hash_alg = alg_class.hashing_alg.name - hash_size = alg_class.hashing_alg.digest_size - hash_function = ( - lambda token: BinaPy(token) - .to(hash_alg)[: hash_size // 2] - .to("b64u") - .ascii() - ) + hash_function = IdToken.hash_method(verification_jwk, id_token_alg) at_hash = id_token.get_claim("at_hash") if at_hash is not None: expected_at_hash = hash_function(self.access_token) if expected_at_hash != at_hash: - raise InvalidIdToken( - f"Mismatching 'at_hash' value: expected '{expected_at_hash}', got '{at_hash}'" - ) + msg = f"Mismatching 'at_hash' value: expected '{expected_at_hash}', got '{at_hash}'" + raise InvalidIdToken(msg) c_hash = id_token.get_claim("c_hash") if c_hash is not None: expected_c_hash = hash_function(azr.code) if expected_c_hash != c_hash: - raise InvalidIdToken( - f"Mismatching 'c_hash' value: expected '{expected_c_hash}', got '{c_hash}'" - ) + msg = f"Mismatching 'c_hash' value: expected '{expected_c_hash}', got '{c_hash}'" + raise InvalidIdToken(msg) s_hash = id_token.get_claim("s_hash") if s_hash is not None: if azr.state is None: - raise InvalidIdToken( - "ID Token has a 's_hash' claim but no state was included in the request." - ) + msg = "ID Token has a 's_hash' claim but no state was included in the request." + raise InvalidIdToken(msg) expected_s_hash = hash_function(azr.state) if expected_s_hash != s_hash: - raise InvalidIdToken( - f"Mismatching 's_hash' value (expected '{expected_s_hash}', got '{s_hash}'" - ) + msg = f"Mismatching 's_hash' value (expected '{expected_s_hash}', got '{s_hash}'" + raise InvalidIdToken(msg) if azr.max_age is not None: try: auth_time = id_token.auth_time except AttributeError: - raise InvalidIdToken( + msg = ( "A `max_age` parameter was included in the authorization request, " "but the ID Token does not contain an `auth_time` claim." ) + raise InvalidIdToken(msg) from None auth_age = datetime.now(tz=timezone.utc) - auth_time if auth_age.seconds > azr.max_age + 60: - raise InvalidIdToken( - "User authentication happened too long ago. " - "The `auth_time` parameter from the ID Token indicate that the last Authentication Time " - f"was at {auth_time} ({auth_age.seconds} sec ago), but the authorization request `max_age` " - f"parameter specified that it must be maximum {azr.max_age} sec ago." + msg = ( + "User authentication happened too long ago. The `auth_time` parameter from" + " the ID Token indicate that the last Authentication Time was at" + f" {auth_time} ({auth_age.seconds} sec ago), but the authorization request" + f" `max_age` parameter specified that it must be maximum {azr.max_age} sec" + " ago." ) - - return id_token + raise InvalidIdToken(msg) + + return self.__class__( + access_token=self.access_token, + expires_at=self.expires_at, + scope=self.scope, + refresh_token=self.refresh_token, + token_type=self.token_type, + id_token=id_token, + **self.kwargs, + ) def __str__(self) -> str: """Return the access token value, as a string. Returns: the access token string + """ return self.access_token - def __contains__(self, key: str) -> bool: - """Check existence of a key in the token response. + def as_dict(self) -> dict[str, Any]: + """Return a dict of parameters. - Allows testing like `assert "refresh_token" in token_response`. + That is suitable for serialization or to init another BearerToken. - Args: - key: a key - - Returns: - `True` if the key exists in the token response, `False` otherwise """ - if key == "access_token": - return True - elif key == "refresh_token": - return self.refresh_token is not None - elif key == "scope": - return self.scope is not None - elif key == "token_type": - return True - elif key == "expires_in": - return self.expires_at is not None - elif key == "id_token": - return self.id_token is not None - else: - return key in self.other + d = asdict(self) + d.pop("expires_at") + d["expires_in"] = self.expires_in + d.update(**d.pop("kwargs", {})) + return {key: val for key, val in d.items() if val is not None} - def __getattr__(self, key: str) -> Any: - """Return attributes from this BearerToken. + @property + def expires_in(self) -> int | None: + """Number of seconds until expiration.""" + if self.expires_at: + return int(self.expires_at.timestamp() - datetime.now(tz=timezone.utc).timestamp()) + return None - Allows `token_response.expires_in` or `token_response.any_custom_attribute`. + def __getattr__(self, key: str) -> Any: + """Return custom attributes from this BearerToken. Args: key: a key @@ -338,73 +389,9 @@ def __getattr__(self, key: str) -> Any: Raises: AttributeError: if the attribute is not found in this response. - """ - if key == "expires_in": - if self.expires_at is None: - return None - return int(self.expires_at.timestamp() - datetime.now().timestamp()) - elif key == "token_type": - return self.TOKEN_TYPE - return self.other.get(key) or super().__getattribute__(key) - - def as_dict(self, expires_at: bool = False) -> dict[str, Any]: - """Return all attributes from this BearerToken as a `dict`. - - Args: - expires_at: if `True`, the dict will contain an extra `expires_at` field with the token expiration date. - Returns - a `dict` containing this BearerToken attributes. """ - r: dict[str, Any] = { - "access_token": self.access_token, - "token_type": self.TOKEN_TYPE, - } - if self.expires_at: - r["expires_in"] = self.expires_in - if expires_at: - r["expires_at"] = self.expires_at - if self.scope: - r["scope"] = self.scope - if self.refresh_token: - r["refresh_token"] = self.refresh_token - if self.id_token: - r["id_token"] = str(self.id_token) - if self.other: - r.update(self.other) - return r - - def __repr__(self) -> str: - """Return a representation of this BearerToken. - - This representation is a pretty formatted `dict` that looks like a Token Endpoint response. - - Returns: - a `str` representation of this BearerToken. - """ - return pprint.pformat(self.as_dict()) - - def __eq__(self, other: object) -> bool: - """Check if this BearerToken is equal to another. - - It supports comparison with another BearerToken, or with an `access_token` as `str`. - - Args: - other: another token to compare to - - Returns: - `True` if equal, `False` otherwise - """ - if isinstance(other, BearerToken): - return ( - self.access_token == other.access_token - and self.refresh_token == other.refresh_token - and self.expires_at == other.expires_at - and self.token_type == other.token_type - ) - elif isinstance(other, str): - return self.access_token == other - return super().__eq__(other) + return self.kwargs.get(key) or super().__getattribute__(key) class BearerTokenSerializer: @@ -412,12 +399,14 @@ class BearerTokenSerializer: This may be used to store BearerTokens in session or cookies. - It needs a `dumper` and a `loader` functions that will respectively serialize and deserialize BearerTokens. - Default implementations are provided with use gzip and base64url on the serialized JSON representation. + It needs a `dumper` and a `loader` functions that will respectively serialize and deserialize + BearerTokens. Default implementations are provided with use gzip and base64url on the serialized + JSON representation. Args: dumper: a function to serialize a token into a `str`. - loader: a function do deserialize a serialized token representation. + loader: a function to deserialize a serialized token representation. + """ def __init__( @@ -437,26 +426,27 @@ def default_dumper(token: BearerToken) -> str: Returns: the serialized value + """ - return BinaPy.serialize_to("json", token.as_dict(True)).to("deflate").to("b64u").ascii() + return BinaPy.serialize_to("json", token.as_dict()).to("deflate").to("b64u").ascii() - def default_loader( - self, serialized: str, token_class: type[BearerToken] = BearerToken - ) -> BearerToken: + def default_loader(self, serialized: str, token_class: type[BearerToken] = BearerToken) -> BearerToken: """Deserialize a BearerToken. This does the opposite operations than `default_dumper`. Args: serialized: the serialized token + token_class: class to use to deserialize the Token Returns: a BearerToken + """ attrs = BinaPy(serialized).decode_from("b64u").decode_from("deflate").parse_from("json") expires_at = attrs.get("expires_at") if expires_at: - attrs["expires_at"] = datetime.fromtimestamp(expires_at) + attrs["expires_at"] = datetime.fromtimestamp(expires_at, tz=timezone.utc) return token_class(**attrs) def dumps(self, token: BearerToken) -> str: @@ -467,6 +457,7 @@ def dumps(self, token: BearerToken) -> str: Returns: the serialized token, as a str + """ return self.dumper(token) @@ -478,5 +469,10 @@ def loads(self, serialized: str) -> BearerToken: Returns: the deserialized token + """ return self.loader(serialized) + + +class DPoPToken(AccessToken): + """Represents a DPoP Token.""" diff --git a/requests_oauth2client/utils.py b/requests_oauth2client/utils.py index 9b769df..57ee8f4 100644 --- a/requests_oauth2client/utils.py +++ b/requests_oauth2client/utils.py @@ -1,20 +1,20 @@ """Various utilities used in multiple places. -This module contains helper methods that are used in multiple places within `requests_oauth2client`. +This module contains helper methods that are used in multiple places. + """ + from __future__ import annotations -from datetime import datetime, timedelta +from datetime import datetime, timedelta, timezone from functools import wraps from typing import Any, Callable -from furl import furl # type: ignore[import-not-found] +from furl import furl # type: ignore[import-untyped] -def validate_endpoint_uri( - uri: str, https: bool = True, no_fragment: bool = True, path: bool = True -) -> None: - """Validate that an URI is suitable as an endpoint URI. +def validate_endpoint_uri(uri: str, *, https: bool = True, no_fragment: bool = True, path: bool = True) -> None: + """Validate that a URI is suitable as an endpoint URI. It checks: @@ -22,7 +22,11 @@ def validate_endpoint_uri( - that no fragment is included - that a path is present - Those check can be individually disabled using the parameters `https`, `no_fragment` and `path`. + Those checks can be individually disabled using the parameters + + - `https` + - `no_fragment` + - `path` Args: uri: the uri @@ -32,14 +36,19 @@ def validate_endpoint_uri( Raises: ValueError: if the supplied url is not suitable + """ url = furl(uri) + msg: list[str] = [] if https and url.scheme != "https": - raise ValueError("url must use https") + msg += "url must use https" if no_fragment and url.fragment: - raise ValueError("url must not contain a fragment") + msg += "url must not contain a fragment" if path and (not url.path or url.path == "/"): - raise ValueError("url has no path") + msg += "url has no path" + + if msg: + raise ValueError(", ".join(msg)) def accepts_expires_in(f: Callable[..., Any]) -> Callable[..., Any]: @@ -48,14 +57,15 @@ def accepts_expires_in(f: Callable[..., Any]) -> Callable[..., Any]: This decorates methods that accept an `expires_at` datetime parameter, to also allow an `expires_in` parameter in seconds. - If supplied, `expires_in` will be converted to a datetime `expires_in` seconds in the future, and passed as `expires_at` - in the decorated method. + If supplied, `expires_in` will be converted to a datetime `expires_in` seconds in the future, + and passed as `expires_at` in the decorated method. Args: f: the method to decorate, with an `expires_at` parameter Returns: a decorated method that accepts either `expires_in` or `expires_at`. + """ @wraps(f) @@ -67,15 +77,10 @@ def decorator( ) -> Any: if expires_in is None and expires_at is None: return f(*args, **kwargs) - if ( - expires_in - and isinstance(expires_in, str) - and expires_in.isdigit() - and int(expires_in) >= 1 - ): - expires_at = datetime.now() + timedelta(seconds=int(expires_in)) + if expires_in and isinstance(expires_in, str) and expires_in.isdigit() and int(expires_in) >= 1: + expires_at = datetime.now(tz=timezone.utc) + timedelta(seconds=int(expires_in)) elif expires_in and isinstance(expires_in, int) and expires_in >= 1: - expires_at = datetime.now() + timedelta(seconds=expires_in) + expires_at = datetime.now(tz=timezone.utc) + timedelta(seconds=expires_in) return f(*args, expires_at=expires_at, **kwargs) return decorator diff --git a/requests_oauth2client/vendor_specific/__init__.py b/requests_oauth2client/vendor_specific/__init__.py index 274d3c1..336d4f9 100644 --- a/requests_oauth2client/vendor_specific/__init__.py +++ b/requests_oauth2client/vendor_specific/__init__.py @@ -2,7 +2,10 @@ This module contains vendor-specific subclasses of [requests_oauth2client] classes, that make it easier to work with specific OAuth 2.x providers and/or fix compatibility issues. + """ -from .auth0 import Auth0Client, Auth0ManagementApiClient -from .ping import PingClient +from .auth0 import Auth0 +from .ping import Ping + +__all__ = ["Auth0", "Ping"] diff --git a/requests_oauth2client/vendor_specific/auth0.py b/requests_oauth2client/vendor_specific/auth0.py index bf2d6da..0d8ca98 100644 --- a/requests_oauth2client/vendor_specific/auth0.py +++ b/requests_oauth2client/vendor_specific/auth0.py @@ -1,34 +1,24 @@ """Implements subclasses for [Auth0](https://auth0.com).""" + from __future__ import annotations from typing import Any import requests +from jwskate import Jwk from requests_oauth2client import ApiClient, OAuth2Client, OAuth2ClientCredentialsAuth -class Auth0Client(OAuth2Client): - """An OAuth2Client for an Auth0 tenant. - - Instead of providing each endpoint URL separately, you only have to provide a - tenant name and all endpoints will be initialized to work with your tenant. - - Args: - tenant: the tenant name or FQDN. If it doesn't contain a `.` or it ends with `.eu`, `.us`, or `.au`, - then `.auth0.com` will automatically be suffixed to the provided tenant name. - auth: the client credentials, same definition as for [OAuth2Client][requests_oauth2client.client.OAuth2Client] - session: the session to use, same definition as for [OAuth2Client][requests_oauth2client.client.OAuth2Client] - """ +class Auth0: + """Auth0-related utilities.""" - def __init__( - self, - tenant: str, - auth: requests.auth.AuthBase | tuple[str, str] | str | None = None, - client_id: str | None = None, - client_secret: str | None = None, - session: requests.Session | None = None, - ): + @classmethod + def tenant(cls, tenant: str) -> str: + """Given a short tenant name, returns the full tenant FQDN.""" + if not tenant: + msg = "You must specify a tenant name." + raise ValueError(msg) if ( "." not in tenant or tenant.endswith(".eu") @@ -37,61 +27,111 @@ def __init__( or tenant.endswith(".jp") ): tenant = f"{tenant}.auth0.com" - if "://" in tenant and not tenant.startswith("https://"): - raise ValueError( - "Invalid tenant name. It must be a tenant name like 'mytenant.myregion' or a full issuer like 'https://mytenant.myregion.auth0.com'." + if "://" in tenant: + if tenant.startswith("https://"): + return tenant[8:] + msg = ( + "Invalid tenant name. " + "It must be a tenant name like 'mytenant.myregion' " + "or a full FQDN like 'mytenant.myregion.auth0.com'." + "or an issuer like 'https://mytenant.myregion.auth0.com'" ) - self.tenant = tenant + raise ValueError(msg) + return tenant + + @classmethod + def client( + cls, + tenant: str, + auth: ( + requests.auth.AuthBase | tuple[str, str] | tuple[str, Jwk] | tuple[str, dict[str, Any]] | str | None + ) = None, + *, + client_id: str | None = None, + client_secret: str | None = None, + private_jwk: Any | None = None, + session: requests.Session | None = None, + **kwargs: Any, + ) -> OAuth2Client: + """Initialise an OAuth2Client for an Auth0 tenant.""" + tenant = cls.tenant(tenant) token_endpoint = f"https://{tenant}/oauth/token" + authorization_endpoint = f"https://{tenant}/authorize" revocation_endpoint = f"https://{tenant}/oauth/revoke" userinfo_endpoint = f"https://{tenant}/userinfo" jwks_uri = f"https://{tenant}/.well-known/jwks.json" - super().__init__( - token_endpoint=token_endpoint, - revocation_endpoint=revocation_endpoint, - userinfo_endpoint=userinfo_endpoint, - jwks_uri=jwks_uri, + + return OAuth2Client( auth=auth, client_id=client_id, client_secret=client_secret, + private_jwk=private_jwk, session=session, + token_endpoint=token_endpoint, + authorization_endpoint=authorization_endpoint, + revocation_endpoint=revocation_endpoint, + userinfo_endpoint=userinfo_endpoint, + issuer=tenant, + jwks_uri=jwks_uri, + **kwargs, ) - -class Auth0ManagementApiClient(ApiClient): - """A wrapper around the Auth0 Management API. - - See [Auth0 Management API v2](https://auth0.com/docs/api/management/v2). - You must provide the target tenant name and the credentials for a client that is allowed access to the Management API. - - Args: - tenant: the tenant name. Same definition as for [Auth0Client][requests_oauth2client.vendor_specific.auth0.Auth0Client] - auth: client credentials. Same definition as for [OAuth2Client][requests_oauth2client.client.OAuth2Client] - session: requests session. Same definition as for [OAuth2Client][requests_oauth2client.client.OAuth2Client] - **kwargs: additional kwargs to pass to the ApiClient base class - - Usage: - ```python - a0mgmt = Auth0ManagementApiClient("mytenant.eu", (client_id, client_secret)) - users = a0mgmt.get("users", params={"page": 0, "per_page": 100}) - ``` - """ - - def __init__( - self, + @classmethod + def management_api_client( + cls, tenant: str, - auth: requests.auth.AuthBase | tuple[str, str] | str | None = None, + auth: ( + requests.auth.AuthBase | tuple[str, str] | tuple[str, Jwk] | tuple[str, dict[str, Any]] | str | None + ) = None, + *, client_id: str | None = None, client_secret: str | None = None, + private_jwk: Any | None = None, session: requests.Session | None = None, **kwargs: Any, - ): - client = Auth0Client( - tenant, auth=auth, client_id=client_id, client_secret=client_secret, session=session + ) -> ApiClient: + """Initialize a client for the Auth0 Management API. + + See [Auth0 Management API v2](https://auth0.com/docs/api/management/v2). You must provide the + target tenant name and the credentials for a client that is allowed access to the Management + API. + + Args: + tenant: the tenant name. + Same definition as for [Auth0.client][requests_oauth2client.vendor_specific.auth0.Auth0.client] + auth: client credentials. + Same definition as for [OAuth2Client][requests_oauth2client.client.OAuth2Client] + client_id: the Client ID. + Same definition as for [OAuth2Client][requests_oauth2client.client.OAuth2Client] + client_secret: the Client Secret. + Same definition as for [OAuth2Client][requests_oauth2client.client.OAuth2Client] + private_jwk: the private key to use for client authentication. + Same definition as for [OAuth2Client][requests_oauth2client.client.OAuth2Client] + session: requests session. + Same definition as for [OAuth2Client][requests_oauth2client.client.OAuth2Client] + **kwargs: additional kwargs to pass to the ApiClient base class + + Usage: + ```python + from requests_oauth2client.vendor_specific import Auth0 + + a0mgmt = Auth0.management_api_client("mytenant.eu", client_id=client_id, client_secret=client_secret) + users = a0mgmt.get("users", params={"page": 0, "per_page": 100}) + ``` + + """ + tenant = cls.tenant(tenant) + client = cls.client( + tenant, + auth=auth, + client_id=client_id, + client_secret=client_secret, + private_jwk=private_jwk, + session=session, ) - audience = f"https://{client.tenant}/api/v2/" + audience = f"https://{tenant}/api/v2/" api_auth = OAuth2ClientCredentialsAuth(client, audience=audience) - super().__init__( + return ApiClient( base_url=audience, auth=api_auth, session=session, diff --git a/requests_oauth2client/vendor_specific/ping.py b/requests_oauth2client/vendor_specific/ping.py index 803325e..f4f2a3e 100644 --- a/requests_oauth2client/vendor_specific/ping.py +++ b/requests_oauth2client/vendor_specific/ping.py @@ -1,37 +1,44 @@ """PingID specific client.""" + from __future__ import annotations +from typing import Any + import requests from requests_oauth2client import OAuth2Client -class PingClient(OAuth2Client): - """A client for PingID Authorization Server. - - This will initialize all endpoints with the PingID specific urls, without using the metadata. - Excepted for avoiding a round-trip to get the metadata url, this does not provide any advantage over using - `OAuth2Client.from_discovery_endpoint(issuer="https://myissuer.domain.tld")` - """ +class Ping: + """Ping Identity related utilities.""" - def __init__( - self, + @classmethod + def client( + cls, issuer: str, auth: requests.auth.AuthBase | tuple[str, str] | str | None = None, client_id: str | None = None, client_secret: str | None = None, + private_jwk: Any = None, session: requests.Session | None = None, - ): + ) -> OAuth2Client: + """Initialize an OAuth2Client for PingFederate. + + This will configure all endpoints with PingID specific urls, without using the metadata. + Excepted for avoiding a round-trip to get the metadata url, this does not provide any advantage + over using `OAuth2Client.from_discovery_endpoint(issuer="https://myissuer.domain.tld")`. + + """ if not issuer.startswith("https://"): - if issuer.__contains__("://"): - raise ValueError("Invalid issuer, must be an https:// url or a domain name") + if "://" in issuer: + msg = "Invalid issuer. It must be an https:// url or a domain name without a scheme." + raise ValueError(msg) issuer = f"https://{issuer}" if "." not in issuer: - raise ValueError( - "Invalid issuer. It must contain at least a dot in the domain name" - ) + msg = "Invalid issuer. It must contain at least a dot in the domain name." + raise ValueError(msg) - super().__init__( + return OAuth2Client( authorization_endpoint=f"{issuer}/as/authorization.oauth2", token_endpoint=f"{issuer}/as/token.oauth2", revocation_endpoint=f"{issuer}/as/revoke_token.oauth2", @@ -47,5 +54,6 @@ def __init__( auth=auth, client_id=client_id, client_secret=client_secret, + private_jwk=private_jwk, session=session, ) diff --git a/tests/conftest.py b/tests/conftest.py index 8bad357..c8f1a5d 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,14 +1,14 @@ from __future__ import annotations import base64 -from datetime import datetime +from datetime import datetime, timezone from typing import TYPE_CHECKING, Any, Callable, Iterable from urllib.parse import parse_qs import pytest import requests import requests_mock -from furl import Query, furl # type: ignore[import-not-found] +from furl import Query, furl # type: ignore[import-untyped] from jwskate import Jwk, JwkSet, SignedJwt, SymmetricJwk from requests_mock import Mocker from requests_mock.request import _RequestObjectProxy @@ -154,9 +154,7 @@ def client_auth_method_handler( @pytest.fixture(scope="session") def client_auth_method( - client_auth_method_handler: ( - type[ClientSecretPost] | type[ClientSecretBasic] | type[ClientSecretJwt] - ), + client_auth_method_handler: type[ClientSecretPost] | type[ClientSecretBasic] | type[ClientSecretJwt], client_id: str, client_secret: str, ) -> ClientSecretPost | ClientSecretBasic | ClientSecretJwt: @@ -187,9 +185,7 @@ def validator(req: _RequestObjectProxy, *, client_id: str) -> None: @pytest.fixture(scope="session") def client_secret_basic_auth_validator() -> RequestValidatorType: def validator(req: _RequestObjectProxy, *, client_id: str, client_secret: str) -> None: - encoded_username_password = base64.b64encode( - f"{client_id}:{client_secret}".encode("ascii") - ).decode() + encoded_username_password = base64.b64encode(f"{client_id}:{client_secret}".encode("ascii")).decode() assert req.headers.get("Authorization") == f"Basic {encoded_username_password}" assert "client_secret" not in req.text @@ -198,9 +194,7 @@ def validator(req: _RequestObjectProxy, *, client_id: str, client_secret: str) - @pytest.fixture(scope="session") def client_secret_jwt_auth_validator() -> RequestValidatorType: - def validator( - req: _RequestObjectProxy, *, client_id: str, client_secret: str, endpoint: str - ) -> None: + def validator(req: _RequestObjectProxy, *, client_id: str, client_secret: str, endpoint: str) -> None: params = Query(req.text).params assert params.get("client_id") == client_id assert "client_assertion" in params @@ -209,7 +203,7 @@ def validator( jwt = SignedJwt(client_assertion) jwt.verify_signature(jwk, alg="HS256") claims = jwt.claims - now = int(datetime.now().timestamp()) + now = int(datetime.now(tz=timezone.utc).timestamp()) assert now - 10 <= claims["iat"] <= now, "unexpected iat" assert now + 10 < claims["exp"] < now + 180, "unexpected exp" assert claims["iss"] == client_id @@ -236,7 +230,7 @@ def validator( jwt = SignedJwt(client_assertion) jwt.verify_signature(public_jwk) claims = jwt.claims - now = int(datetime.now().timestamp()) + now = int(datetime.now(timezone.utc).timestamp()) assert now - 10 <= claims["iat"] <= now, "Unexpected iat" assert now + 10 < claims["exp"] < now + 180, "unexpected exp" assert claims["iss"] == client_id @@ -255,7 +249,9 @@ def validator(req: _RequestObjectProxy, *, scope: str | None = None, **kwargs: A if scope is not None and not isinstance(scope, str): scope = " ".join(scope) - assert params.get("scope") == scope + assert ( + not scope and params.get("scope") is None or scope and params.get("scope") == scope + ), f"expected {scope=}, got {params.get('scope')=}" for key, val in kwargs.items(): assert params.get(key) == val @@ -336,18 +332,32 @@ def validator(req: _RequestObjectProxy, *, auth_req_id: str, **kwargs: Any) -> N @pytest.fixture(scope="session") def backchannel_auth_request_validator() -> RequestValidatorType: def validator( - req: _RequestObjectProxy, *, scope: None | str | list[str], **kwargs: Any + req: _RequestObjectProxy, + *, + scope: None | str | Iterable[str], + acr_values: None | str | Iterable[str] = None, + **kwargs: Any, ) -> None: params = Query(req.text).params + if scope is None: assert "scope" not in params elif isinstance(scope, str): assert params.get("scope") == scope else: assert params.get("scope") == " ".join(scope) + + if acr_values is None: + assert acr_values not in params + elif isinstance(acr_values, str): + assert params.get("acr_values") == acr_values + else: + assert params.get("acr_values") == " ".join(acr_values) + login_hint = params.get("login_hint") login_hint_token = params.get("login_hint_token") id_token_hint = params.get("id_token_hint") + assert login_hint or login_hint_token or id_token_hint assert ( not (login_hint and login_hint_token) diff --git a/tests/test_authorization_code.py b/tests/test_authorization_code.py index 8e3ca90..f41d954 100644 --- a/tests/test_authorization_code.py +++ b/tests/test_authorization_code.py @@ -1,22 +1,120 @@ import base64 import hashlib import secrets -from datetime import datetime, timedelta +from datetime import datetime, timedelta, timezone import requests -from furl import Query, furl # type: ignore[import-not-found] +from freezegun import freeze_time +from furl import Query, furl # type: ignore[import-untyped] +from jwskate import Jwk from requests_mock import Mocker from requests_oauth2client import ( AuthorizationRequest, BearerToken, ClientSecretPost, + IdToken, OAuth2Client, oidc_discovery_document_url, ) +@freeze_time("2023-12-31T23:59:59") def test_authorization_code( + session: requests.Session, + requests_mock: Mocker, + issuer: str, + token_endpoint: str, + authorization_endpoint: str, + jwks_uri: str, + discovery_document: str, + client_id: str, + client_secret: str, + redirect_uri: str, + scope: str, + audience: str, +) -> None: + id_token_sig_alg = "ES256" + id_token_signing_key = Jwk.generate(alg=id_token_sig_alg).with_kid_thumbprint() + + requests_mock.get(issuer + "/.well-known/openid-configuration", json=discovery_document) + requests_mock.get(jwks_uri, json={"keys": [id_token_signing_key.public_jwk().to_dict()]}) + client = OAuth2Client.from_discovery_endpoint( + issuer=issuer, + client_id=client_id, + client_secret=client_secret, + redirect_uri=redirect_uri, + id_token_signed_response_alg=id_token_sig_alg, + ) + authorization_request = client.authorization_request(scope=scope, audience=audience) + assert authorization_request.authorization_endpoint == authorization_endpoint + assert authorization_request.client_id == client_id + assert authorization_request.response_type == "code" + assert authorization_request.redirect_uri == redirect_uri + assert authorization_request.scope is not None + assert " ".join(authorization_request.scope) == scope + assert authorization_request.state is not None + assert authorization_request.nonce is not None + assert authorization_request.audience == audience + assert authorization_request.code_challenge_method == "S256" + assert authorization_request.code_challenge is not None + + authorization_code = secrets.token_urlsafe() + state = authorization_request.state + + authorization_response = furl(redirect_uri, query={"code": authorization_code, "state": state}).url + + access_token = secrets.token_urlsafe() + + c_hash = IdToken.hash_method(id_token_signing_key)(authorization_code) + at_hash = IdToken.hash_method(id_token_signing_key)(access_token) + s_hash = IdToken.hash_method(id_token_signing_key)(state) + + id_token = IdToken.sign( + { + "iss": issuer, + "sub": "248289761001", + "aud": client_id, + "nonce": authorization_request.nonce, + "iat": IdToken.timestamp(), + "exp": IdToken.timestamp(60), + "c_hash": c_hash, + "at_hash": at_hash, + "s_hash": s_hash, + "auth_time": IdToken.timestamp(), + }, + key=id_token_signing_key, + ) + code_verifier = authorization_request.code_verifier + assert code_verifier is not None + assert ( + base64.urlsafe_b64encode(hashlib.sha256(code_verifier.encode()).digest()).rstrip(b"=") + == authorization_request.code_challenge.encode() + ) + + auth_response = authorization_request.validate_callback(authorization_response) + + requests_mock.post( + token_endpoint, + json={"access_token": access_token, "token_type": "Bearer", "expires_in": 3600, "id_token": str(id_token)}, + ) + token = client.authorization_code(code=auth_response) + + assert isinstance(token, BearerToken) + assert token.access_token == access_token + assert not token.is_expired() + assert token.expires_at is not None + assert token.expires_at == datetime.now(tz=timezone.utc) + timedelta(seconds=3600) + + assert requests_mock.last_request is not None + params = Query(requests_mock.last_request.text).params + assert params.get("client_id") == client_id + assert params.get("client_secret") == client_secret + assert params.get("grant_type") == "authorization_code" + assert params.get("code") == authorization_code + + +def test_authorization_code_legacy( session: requests.Session, requests_mock: Mocker, issuer: str, @@ -37,7 +135,7 @@ def test_authorization_code( authorization_request = AuthorizationRequest( authorization_endpoint, - client_id, + client_id=client_id, redirect_uri=redirect_uri, scope=scope, audience=audience, @@ -47,9 +145,7 @@ def test_authorization_code( state = authorization_request.state - authorization_response = furl( - redirect_uri, query={"code": authorization_code, "state": state} - ).url + authorization_response = furl(redirect_uri, query={"code": authorization_code, "state": state}).url requests_mock.get( authorization_request.uri, status_code=302, @@ -86,18 +182,16 @@ def test_authorization_code( token_endpoint, json={"access_token": access_token, "token_type": "Bearer", "expires_in": 3600}, ) - token = client.authorization_code( - code=auth_response, redirect_uri=redirect_uri, validate=False - ) + token = client.authorization_code(code=auth_response, redirect_uri=redirect_uri, validate=False) assert isinstance(token, BearerToken) assert token.access_token == access_token assert not token.is_expired() assert token.expires_at is not None assert ( - datetime.now() + timedelta(seconds=3598) + datetime.now(tz=timezone.utc) + timedelta(seconds=3598) <= token.expires_at - <= datetime.now() + timedelta(seconds=3600) + <= datetime.now(tz=timezone.utc) + timedelta(seconds=3600) ) assert requests_mock.last_request is not None diff --git a/tests/test_device_authorization.py b/tests/test_device_authorization.py index 412ce22..0fe2d4c 100644 --- a/tests/test_device_authorization.py +++ b/tests/test_device_authorization.py @@ -1,7 +1,7 @@ import secrets import pytest -from furl import Query # type: ignore[import-not-found] +from furl import Query # type: ignore[import-untyped] from requests_mock import Mocker from requests_oauth2client import ( diff --git a/tests/test_oidc.py b/tests/test_oidc.py new file mode 100644 index 0000000..127fd61 --- /dev/null +++ b/tests/test_oidc.py @@ -0,0 +1,91 @@ +from freezegun import freeze_time +from furl import furl # type: ignore[import-untyped] +from jwskate import EncryptionAlgs, Jwk, Jwt + +from requests_oauth2client import IdToken, OAuth2Client +from tests.conftest import RequestsMocker + + +@freeze_time("2024-01-01 00:00:00") +def test_encrypted_id_token(requests_mock: RequestsMocker) -> None: + id_token_decryption_key = Jwk( + { + "kty": "EC", + "crv": "P-256", + "x": "GNWWCtwaKIdNjsz_ypPKEX1If_yL5w_mJeAepqEDNdk", + "y": "qjfk0Og-Ov9cWxtuR3-Oxcr4MqW9LB4FLkQuo-ryUWE", + "d": "y-ndvYzmafoeY9AlnUkoXIiNe5xf_h_23NEEATYKoY4", + "alg": "ECDH-ES+A256KW", + "kid": "RvIJrxavhz4CLxA9woSdt4szQkvBIxJtR_s8huPIfIQ", + } + ) + id_token_encryption_key = id_token_decryption_key.public_jwk() + + id_token_signature_key = Jwk( + { + "kty": "EC", + "crv": "P-256", + "x": "Q9nRvw5sxTnl93FWc3oHvvbfREUt_1on0WVucVqSPvw", + "y": "2dNrVWA0LHTwC8vOChVR29HbesoLCwbvaHwHcqKQSG4", + "d": "pfpik5SEnMh6NcegGPrI0XOlf2YIx4wB7hws6-kO1fE", + "alg": "ES256", + "kid": "uiSjaT2_mswJWSBQ6Oj78RjpPnAQVz0iDkyLZHEkFvc", + } + ) + id_token_verification_key = id_token_signature_key.public_jwk() + + subject = "user1" + nonce = "mynonce" + + client_id = "myclientid" + private_key = Jwk( + { + "kty": "EC", + "crv": "P-256", + "x": "mKV-T7IbQJwt6sakGn9kN3dCyMWIa3XqA_EyIUs_jzc", + "y": "8sy4p5BzWwDjAULMokrgkCJwaPWNICTozriOUUA_KQ8", + "d": "xitL_m0Y1lxjoOQINYcynNTJU-EopW4NiBeiMWE-3O8", + "alg": "ES256", + "kid": "Vs6sw5LGsEYfeiAs3rwiOwXKJpw4S926IaOpefvm-Ec", + } + ) + token_endpoint = "https://token.endpoint" + authorization_endpoint = "https://authorization.endpoint" + issuer = "https://issuer" + + claims = {"iss": issuer, "iat": Jwt.timestamp(), "exp": Jwt.timestamp(60), "sub": subject, "nonce": nonce} + id_token = Jwt.sign_and_encrypt( + claims, sign_key=id_token_signature_key, enc_key=id_token_encryption_key, enc=EncryptionAlgs.A256CBC_HS512 + ) + + redirect_uri = "http://localhost:12345/callback" + client = OAuth2Client( + client_id=client_id, + private_key=private_key, + issuer=issuer, + token_endpoint=token_endpoint, + authorization_endpoint=authorization_endpoint, + redirect_uri=redirect_uri, + id_token_signed_response_alg="ES256", + id_token_decryption_key=id_token_decryption_key, + authorization_server_jwks=id_token_verification_key.as_jwks(), + ) + + state = "mystate" + + authorization_code = "authorization_code" + authorization_request = client.authorization_request(scope="openid", state=state, nonce=nonce) + + authorization_response = authorization_request.validate_callback( + furl(redirect_uri).add(args={"code": authorization_code, "state": state, "iss": issuer}) + ) + + access_token = "my_access_token" + + requests_mock.post( + token_endpoint, + json={"access_token": access_token, "token_type": "Bearer", "expires_in": 3600, "id_token": str(id_token)}, + ) + token_resp = client.authorization_code(authorization_response, validate=True) + assert isinstance(token_resp.id_token, IdToken) + assert token_resp.id_token.claims == claims diff --git a/tests/test_refresh_token.py b/tests/test_refresh_token.py index 0b7c0a5..35a344e 100644 --- a/tests/test_refresh_token.py +++ b/tests/test_refresh_token.py @@ -39,22 +39,16 @@ def test_refresh_token( assert token_resp.refresh_token == new_refresh_token refresh_token_grant_validator(requests_mock.last_request, refresh_token=refresh_token) - client_secret_post_auth_validator( - requests_mock.last_request, client_id=client_id, client_secret=client_secret - ) + client_secret_post_auth_validator(requests_mock.last_request, client_id=client_id, client_secret=client_secret) requests_mock.post(revocation_endpoint) assert client.revoke_access_token(token_resp.access_token) is True revocation_request_validator(requests_mock.last_request, new_access_token, "access_token") - client_secret_post_auth_validator( - requests_mock.last_request, client_id=client_id, client_secret=client_secret - ) + client_secret_post_auth_validator(requests_mock.last_request, client_id=client_id, client_secret=client_secret) assert client.revoke_refresh_token(token_resp.refresh_token) is True revocation_request_validator(requests_mock.last_request, new_refresh_token, "refresh_token") - client_secret_post_auth_validator( - requests_mock.last_request, client_id=client_id, client_secret=client_secret - ) + client_secret_post_auth_validator(requests_mock.last_request, client_id=client_id, client_secret=client_secret) diff --git a/tests/test_token_exchange.py b/tests/test_token_exchange.py index 8e3022b..7ca6769 100644 --- a/tests/test_token_exchange.py +++ b/tests/test_token_exchange.py @@ -1,12 +1,14 @@ import secrets import pytest -from furl import Query # type: ignore[import-not-found] +from freezegun import freeze_time +from furl import Query # type: ignore[import-untyped] from requests_oauth2client import BearerToken, ClientSecretPost, IdToken, OAuth2Client from tests.conftest import RequestsMocker +@freeze_time() def test_token_exchange( requests_mock: RequestsMocker, client_id: str, @@ -29,14 +31,12 @@ def test_token_exchange( subject_token = "accVkjcJyb4BWCxGsndESCJQbdFMogUC5PbRDqceLTC" resource = "https://backend.example.com/api" - token_response = client.token_exchange( - subject_token=BearerToken(subject_token), resource=resource - ) + token_response = client.token_exchange(subject_token=BearerToken(subject_token), resource=resource) assert token_response.access_token == access_token assert token_response.issued_token_type == "urn:ietf:params:oauth:token-type:access_token" assert token_response.token_type == "Bearer" - assert 58 <= token_response.expires_in <= 60 + assert token_response.expires_in == 60 assert requests_mock.last_request is not None params = Query(requests_mock.last_request.text).params @@ -70,32 +70,18 @@ def test_token_type() -> None: OAuth2Client.get_token_type("urn:ietf:params:oauth:token-type:saml2") == "urn:ietf:params:oauth:token-type:saml2" ) - assert ( - OAuth2Client.get_token_type("urn:ietf:params:oauth:token-type:jwt") - == "urn:ietf:params:oauth:token-type:jwt" - ) + assert OAuth2Client.get_token_type("urn:ietf:params:oauth:token-type:jwt") == "urn:ietf:params:oauth:token-type:jwt" - assert ( - OAuth2Client.get_token_type("access_token") - == "urn:ietf:params:oauth:token-type:access_token" - ) - assert ( - OAuth2Client.get_token_type("refresh_token") - == "urn:ietf:params:oauth:token-type:refresh_token" - ) - assert ( - OAuth2Client.get_token_type("id_token") == "urn:ietf:params:oauth:token-type:id_token" - ) + assert OAuth2Client.get_token_type("access_token") == "urn:ietf:params:oauth:token-type:access_token" + assert OAuth2Client.get_token_type("refresh_token") == "urn:ietf:params:oauth:token-type:refresh_token" + assert OAuth2Client.get_token_type("id_token") == "urn:ietf:params:oauth:token-type:id_token" assert OAuth2Client.get_token_type("saml1") == "urn:ietf:params:oauth:token-type:saml1" assert OAuth2Client.get_token_type("saml2") == "urn:ietf:params:oauth:token-type:saml2" assert OAuth2Client.get_token_type("jwt") == "urn:ietf:params:oauth:token-type:jwt" assert OAuth2Client.get_token_type("foobar") == "foobar" - assert ( - OAuth2Client.get_token_type(token=BearerToken("mytoken")) - == "urn:ietf:params:oauth:token-type:access_token" - ) + assert OAuth2Client.get_token_type(token=BearerToken("mytoken")) == "urn:ietf:params:oauth:token-type:access_token" assert ( OAuth2Client.get_token_type( token_type="refresh_token", @@ -103,10 +89,7 @@ def test_token_type() -> None: ) == "urn:ietf:params:oauth:token-type:refresh_token" ) - assert ( - OAuth2Client.get_token_type("id_token", token="foo") - == "urn:ietf:params:oauth:token-type:id_token" - ) + assert OAuth2Client.get_token_type("id_token", token="foo") == "urn:ietf:params:oauth:token-type:id_token" assert OAuth2Client.get_token_type("saml1") == "urn:ietf:params:oauth:token-type:saml1" assert OAuth2Client.get_token_type("saml2") == "urn:ietf:params:oauth:token-type:saml2" assert OAuth2Client.get_token_type("jwt") == "urn:ietf:params:oauth:token-type:jwt" diff --git a/tests/unit_tests/conftest.py b/tests/unit_tests/conftest.py index 84dbdc8..8adcee6 100644 --- a/tests/unit_tests/conftest.py +++ b/tests/unit_tests/conftest.py @@ -6,9 +6,8 @@ import pytest import requests -from furl import furl # type: ignore[import-not-found] +from furl import furl # type: ignore[import-untyped] from jwskate import Jwk -from typing_extensions import Literal from requests_oauth2client import ( ApiClient, @@ -162,11 +161,7 @@ def client_secret() -> str: @pytest.fixture(scope="session") def client_credential( client_auth_method_handler: ( - type[PublicApp] - | type[ClientSecretPost] - | type[ClientSecretBasic] - | type[ClientSecretJwt] - | type[PrivateKeyJwt] + type[PublicApp] | type[ClientSecretPost] | type[ClientSecretBasic] | type[ClientSecretJwt] | type[PrivateKeyJwt] ), client_secret: str, private_jwk: Jwk, @@ -187,11 +182,7 @@ def client_credential( @pytest.fixture(scope="session") def client_auth_method( client_auth_method_handler: ( - type[PublicApp] - | type[ClientSecretPost] - | type[ClientSecretBasic] - | type[ClientSecretJwt] - | type[PrivateKeyJwt] + type[PublicApp] | type[ClientSecretPost] | type[ClientSecretBasic] | type[ClientSecretJwt] | type[PrivateKeyJwt] ), client_id: str, client_credential: None | str | Jwk, @@ -328,9 +319,9 @@ def sub() -> str: @pytest.fixture( scope="session", - params=[None, "state", True], + params=[None, "state", ...], ) -def state(request: FixtureRequest) -> None | Literal[True] | str: +def state(request: FixtureRequest) -> None | ellipsis | str: return request.param @@ -346,9 +337,9 @@ def auth_request_kwargs(request: FixtureRequest) -> dict[str, Any]: @pytest.fixture( scope="session", - params=[None, "nonce", True], + params=[None, "nonce", ...], ) -def nonce(request: FixtureRequest) -> None | bool | str: +def nonce(request: FixtureRequest) -> None | ellipsis | str: return request.param @@ -382,8 +373,8 @@ def authorization_request( client_id: str, redirect_uri: str, scope: None | str | list[str], - state: None | Literal[True] | str, - nonce: None | Literal[True] | str, + state: None | ellipsis | str, + nonce: None | ellipsis | str, code_verifier: str, code_challenge_method: str, expected_issuer: str | None, @@ -423,7 +414,7 @@ def authorization_request( **auth_request_kwargs, ) - if nonce is True: + if nonce is ...: if scope is not None and "openid" in scope: generated_nonce = args.pop("nonce") assert isinstance(generated_nonce, str) @@ -440,7 +431,7 @@ def authorization_request( else: assert False - if state is True: + if state is ...: generated_state = args.pop("state") assert isinstance(generated_state, str) assert len(generated_state) > 20 @@ -452,15 +443,15 @@ def authorization_request( assert args.pop("state") == state assert azr.state == state - if scope is None: - assert azr.scope is None + if not scope: + assert not azr.scope assert "scope" not in args del expected_args["scope"] elif isinstance(scope, tuple): expected_args["scope"] = " ".join(scope) assert azr.scope == scope elif isinstance(scope, str): - assert azr.scope == scope.split() + assert azr.scope == tuple(scope.split()) if code_challenge_method is None: assert "code_challenge_method" not in args diff --git a/tests/unit_tests/test_api_client.py b/tests/unit_tests/test_api_client.py index 62e3617..bdc74cd 100644 --- a/tests/unit_tests/test_api_client.py +++ b/tests/unit_tests/test_api_client.py @@ -10,7 +10,7 @@ def test_session_at_init() -> None: session = requests.Session() - api = ApiClient(session=session) + api = ApiClient("https://test.local", session=session) assert api.session == session @@ -125,28 +125,15 @@ def test_fail( bearer_auth_validator(requests_mock.last_request, access_token=access_token) -def test_no_url_at_init(requests_mock: RequestsMocker, target_api: str) -> None: - api_client = ApiClient() - requests_mock.get(target_api) - resp = api_client.get(target_api) - assert resp.ok - - -def test_no_url_fail() -> None: - api_client = ApiClient() - with pytest.raises(ValueError): - api_client.get() - - def test_url_as_bytes(requests_mock: RequestsMocker, target_api: str) -> None: api = ApiClient(target_api) - requests_mock.get(target_api) - resp = api.get() - assert resp.ok - resp = api.get(target_api.encode()) + requests_mock.get(urljoin(target_api, "foo/bar")) + resp = api.get((b"foo", b"bar")) assert resp.ok + assert api.get(b"foo/bar").ok + def test_url_as_iterable(requests_mock: RequestsMocker, target_api: str) -> None: api = ApiClient(target_api) @@ -171,6 +158,13 @@ def test_url_as_iterable(requests_mock: RequestsMocker, target_api: str) -> None assert requests_mock.last_request.method == "GET" assert requests_mock.last_request.url == target_uri + class NonStringableObject: + def __str__(self) -> str: + raise ValueError() + + with pytest.raises(TypeError, match="iterable of string-able objects"): + api.get(("resource", NonStringableObject())) # type: ignore[arg-type] + def test_raise_for_status(requests_mock: RequestsMocker, target_api: str) -> None: api = ApiClient(target_api, raise_for_status=False) @@ -189,20 +183,13 @@ def test_raise_for_status(requests_mock: RequestsMocker, target_api: str) -> Non def test_other_api( - requests_mock: RequestsMocker, access_token: str, bearer_auth: BearerAuth, bearer_auth_validator: RequestValidatorType, ) -> None: api = ApiClient("https://some.api/foo", auth=bearer_auth) - other_api = "https://other.api/somethingelse" - requests_mock.get(other_api, json={"status": "success"}) - response = api.get(other_api) - assert response.ok - assert requests_mock.last_request is not None - assert requests_mock.last_request.method == "GET" - assert requests_mock.last_request.url == other_api - bearer_auth_validator(requests_mock.last_request, access_token=access_token) + with pytest.raises(ValueError): + api.get("https://other.api/somethingelse") def test_url_type(target_api: str) -> None: @@ -299,6 +286,9 @@ def test_bool_fields(requests_mock: RequestsMocker, target_api: str) -> None: assert requests_mock.last_request.query == "foo=bar&true=1&false=0" assert requests_mock.last_request.text == "foo=bar&true=1&false=0" + with pytest.raises(ValueError, match="2 value tuple"): + ApiClient(target_api).get(bool_fields=(1, 2, 3)) + def test_getattr(requests_mock: RequestsMocker, target_api: str) -> None: api = ApiClient(target_api) @@ -343,3 +333,9 @@ def test_contextmanager(requests_mock: RequestsMocker, target_api: str) -> None: api.post() assert requests_mock.last_request is not None + + +def test_invalid_url() -> None: + api = ApiClient(None) # type: ignore[arg-type] + with pytest.raises(ValueError, match="Unable to determine an absolute url."): + api.get() diff --git a/tests/unit_tests/test_auth.py b/tests/unit_tests/test_auth.py index 69911cf..1ff773f 100644 --- a/tests/unit_tests/test_auth.py +++ b/tests/unit_tests/test_auth.py @@ -1,4 +1,4 @@ -from datetime import datetime, timedelta +from datetime import datetime, timedelta, timezone from urllib.parse import parse_qs import pytest @@ -19,7 +19,7 @@ @pytest.fixture() def minutes_ago() -> datetime: - return datetime.now() - timedelta(minutes=3) + return datetime.now(tz=timezone.utc) - timedelta(minutes=3) def test_bearer_auth( @@ -64,9 +64,7 @@ def test_access_token_auth( new_access_token = "new_access_token" new_refresh_token = "new_refresh_token" - token = BearerToken( - access_token=access_token, refresh_token=refresh_token, expires_at=minutes_ago - ) + token = BearerToken(access_token=access_token, refresh_token=refresh_token, expires_at=minutes_ago) client = OAuth2Client(token_endpoint, (client_id, client_secret)) auth = OAuth2AccessTokenAuth(client, token) @@ -166,9 +164,7 @@ def test_ropc_auth( username = "my_user1" password = "T0t@lly_5eCur3!" - auth = OAuth2ResourceOwnerPasswordAuth( - client=oauth2client, username=username, password=password - ) + auth = OAuth2ResourceOwnerPasswordAuth(client=oauth2client, username=username, password=password) requests_mock.post( token_endpoint, @@ -249,9 +245,7 @@ def test_device_code_auth( ) requests_mock.post(target_api) - auth = OAuth2DeviceCodeAuth( - client=oauth2client, device_code=da_resp.device_code, interval=1, expires_in=60 - ) + auth = OAuth2DeviceCodeAuth(client=oauth2client, device_code=da_resp.device_code, interval=1, expires_in=60) assert requests.post(target_api, auth=auth) assert len(requests_mock.request_history) == 2 device_code_request = requests_mock.request_history[0] diff --git a/tests/unit_tests/test_authorization_request.py b/tests/unit_tests/test_authorization_request.py index 620bd51..8f62c83 100644 --- a/tests/unit_tests/test_authorization_request.py +++ b/tests/unit_tests/test_authorization_request.py @@ -1,38 +1,39 @@ from __future__ import annotations +from typing import Any + import jwskate import pytest from freezegun import freeze_time -from furl import furl # type: ignore[import-not-found] +from furl import furl # type: ignore[import-untyped] from jwskate import JweCompact, Jwk, Jwt, SignedJwt from requests_oauth2client import ( AuthorizationRequest, AuthorizationRequestSerializer, + AuthorizationResponse, AuthorizationResponseError, MismatchingIssuer, MismatchingState, MissingAuthCode, MissingIssuer, - RequestUriParameterAuthorizationRequest, + RequestUriParameterAuthorizationRequest, RequestParameterAuthorizationRequest, ) def test_authorization_url(authorization_request: AuthorizationRequest) -> None: url = authorization_request.furl - assert dict(url.args) == { - key: val for key, val in authorization_request.args.items() if val is not None - } + assert dict(url.args) == {key: val for key, val in authorization_request.args.items() if val is not None} def test_authorization_signed_request( - authorization_request: AuthorizationRequest, private_jwk: Jwk, public_jwk: Jwk + authorization_request: AuthorizationRequest, private_jwk: Jwk, public_jwk: Jwk, auth_request_kwargs: dict[str, Any] ) -> None: - args = { - key: value for key, value in authorization_request.args.items() if value is not None - } - signed_request = authorization_request.sign(private_jwk) + args = {key: value for key, value in authorization_request.args.items() if value is not None} + signed_request = authorization_request.sign(private_jwk, custom_attr="custom_value") + assert isinstance(signed_request, RequestParameterAuthorizationRequest) assert isinstance(signed_request.uri, str) + assert signed_request.custom_attr == "custom_value" url = signed_request.furl request = url.args.get("request") jwt = Jwt(request) @@ -45,9 +46,7 @@ def test_authorization_signed_request( def test_authorization_signed_request_with_lifetime( authorization_request: AuthorizationRequest, private_jwk: Jwk, public_jwk: Jwk ) -> None: - args = { - key: value for key, value in authorization_request.args.items() if value is not None - } + args = {key: value for key, value in authorization_request.args.items() if value is not None} args["iat"] = 1665409020 args["exp"] = 1665409080 signed_request = authorization_request.sign(private_jwk, lifetime=60) @@ -70,9 +69,7 @@ def enc_jwk() -> Jwk: def test_authorization_signed_and_encrypted_request( authorization_request: AuthorizationRequest, private_jwk: Jwk, public_jwk: Jwk, enc_jwk: Jwk ) -> None: - args = { - key: value for key, value in authorization_request.args.items() if value is not None - } + args = {key: value for key, value in authorization_request.args.items() if value is not None} args["iat"] = 1665409020 args["exp"] = 1665409080 signed_and_encrypted_request = authorization_request.sign_and_encrypt( @@ -86,12 +83,8 @@ def test_authorization_signed_and_encrypted_request( assert Jwt.decrypt_and_verify(jwt, enc_jwk, public_jwk).claims == args -@pytest.mark.parametrize( - "request_uri", ("this_is_a_request_uri", "https://foo.bar/request_uri") -) -def test_request_uri_authorization_request( - authorization_endpoint: str, client_id: str, request_uri: str -) -> None: +@pytest.mark.parametrize("request_uri", ("this_is_a_request_uri", "https://foo.bar/request_uri")) +def test_request_uri_authorization_request(authorization_endpoint: str, client_id: str, request_uri: str) -> None: request_uri_azr = RequestUriParameterAuthorizationRequest( authorization_endpoint=authorization_endpoint, client_id=client_id, @@ -99,9 +92,25 @@ def test_request_uri_authorization_request( ) assert isinstance(request_uri_azr.uri, str) url = request_uri_azr.furl + assert url.origin+url.pathstr == authorization_endpoint assert url.args == {"client_id": client_id, "request_uri": request_uri} +def test_request_uri_authorization_request_with_custom_param(authorization_endpoint: str) -> None: + request_uri = "request_uri" + custom_attr = "custom_attr" + client_id = "client_id" + request_uri_azr = RequestUriParameterAuthorizationRequest( + authorization_endpoint=authorization_endpoint, + client_id=client_id, + request_uri=request_uri, + custom_attr=custom_attr + ) + assert isinstance(request_uri_azr.uri, str) + url = request_uri_azr.furl + assert url.origin+url.pathstr == authorization_endpoint + assert url.args == {"client_id": client_id, "request_uri": request_uri, "custom_attr": custom_attr} + @pytest.mark.parametrize("error", ("consent_required",)) def test_error_response( authorization_request: AuthorizationRequest, @@ -114,9 +123,7 @@ def test_error_response( authorization_request.validate_callback(authorization_response_uri) -def test_missing_code( - authorization_request: AuthorizationRequest, authorization_response_uri: furl -) -> None: +def test_missing_code(authorization_request: AuthorizationRequest, authorization_response_uri: furl) -> None: authorization_response_uri.args.pop("code") with pytest.raises(MissingAuthCode): authorization_request.validate_callback(authorization_response_uri) @@ -178,7 +185,7 @@ def test_authorization_request_serializer(authorization_request: AuthorizationRe assert serializer.loads(serialized) == authorization_request -def test_acr_values() -> None: +def test_request_acr_values() -> None: # you may provide acr_values as a space separated list or as a real list assert AuthorizationRequest( "https://as.local/authorize", @@ -186,14 +193,14 @@ def test_acr_values() -> None: redirect_uri="http://localhost/local", scope="openid", acr_values="1 2 3", - ).acr_values == ["1", "2", "3"] + ).acr_values == ("1", "2", "3") assert AuthorizationRequest( "https://as.local/authorize", client_id="foo", redirect_uri="http://localhost/local", scope="openid", acr_values=("1", "2", "3"), - ).acr_values == ["1", "2", "3"] + ).acr_values == ("1", "2", "3") def test_code_challenge() -> None: @@ -228,3 +235,57 @@ def test_invalid_max_age() -> None: scope="openid", max_age=-1, ) + + +def test_acr_values() -> None: + acr_values = ("reinforced", "strong") + assert ( + AuthorizationResponse( + code="code", + client_id="foo", + redirect_uri="http://localhost/local", + scope="openid", + acr_values=list(acr_values), + ).acr_values + == acr_values + ) + + +def test_custom_attrs() -> None: + custom = "foobar" + azresp = AuthorizationResponse( + code="code", client_id="foo", redirect_uri="http://localhost/local", scope="openid", custom=custom + ) + assert azresp.custom == custom + + +def test_request_as_dict() -> None: + assert AuthorizationRequest( + "https://authorization.endpoint", + client_id="foo", + redirect_uri="http://localhost/local", + scope="openid", + acr_values="1 2 3", + customattr="customvalue", + code_verifier="Jdvs0V61iQz3TGoPP_wjwPUIUHPZ7KYDXnQVKJ3f63MvDFhKFMLusp2JOZKoHEUizGvC5xUWlr4m8FemSvo7gERO8b3G87hB-oOGogPiqmTh_c_ISiDpFENXiFNDaAH3", + nonce="mynonce", + state="mystate", + issuer="https://my.issuer", + authorization_response_iss_parameter_supported=True, + max_age=0, + ).as_dict() == { + "authorization_endpoint": "https://authorization.endpoint", + "client_id": "foo", + "redirect_uri": "http://localhost/local", + "response_type": "code", + "scope": ("openid",), + "acr_values": ("1", "2", "3"), + "code_verifier": "Jdvs0V61iQz3TGoPP_wjwPUIUHPZ7KYDXnQVKJ3f63MvDFhKFMLusp2JOZKoHEUizGvC5xUWlr4m8FemSvo7gERO8b3G87hB-oOGogPiqmTh_c_ISiDpFENXiFNDaAH3", + "code_challenge_method": "S256", + "nonce": "mynonce", + "state": "mystate", + "issuer": "https://my.issuer", + "authorization_response_iss_parameter_supported": True, + "max_age": 0, + "customattr": "customvalue", + } diff --git a/tests/unit_tests/test_backchannel_authentication.py b/tests/unit_tests/test_backchannel_authentication.py index bfd354a..4889b67 100644 --- a/tests/unit_tests/test_backchannel_authentication.py +++ b/tests/unit_tests/test_backchannel_authentication.py @@ -3,6 +3,7 @@ from datetime import datetime import pytest +from freezegun import freeze_time from jwskate import Jwk from requests_oauth2client import ( @@ -18,9 +19,7 @@ def test_backchannel_authentication_response(auth_req_id: str) -> None: - bca_resp = BackChannelAuthenticationResponse( - auth_req_id=auth_req_id, expires_in=10, interval=10, foo="bar" - ) + bca_resp = BackChannelAuthenticationResponse(auth_req_id=auth_req_id, expires_in=10, interval=10, foo="bar") assert bca_resp.auth_req_id == auth_req_id assert bca_resp.interval == 10 @@ -62,6 +61,7 @@ def bca_client( return bca_client +@freeze_time() def test_backchannel_authentication( requests_mock: RequestsMocker, backchannel_authentication_endpoint: str, @@ -69,49 +69,58 @@ def test_backchannel_authentication( auth_req_id: str, scope: None | str | list[str], backchannel_auth_request_validator: RequestValidatorType, + token_endpoint: str, + access_token: str, ) -> None: requests_mock.post( backchannel_authentication_endpoint, json={"auth_req_id": auth_req_id, "expires_in": 360, "interval": 3}, ) - bca_resp = bca_client.backchannel_authentication_request( - scope=scope, login_hint="user@example.com" - ) + bca_resp = bca_client.backchannel_authentication_request(scope=scope, login_hint="user@example.com") assert requests_mock.called_once - backchannel_auth_request_validator( - requests_mock.last_request, scope=scope, login_hint="user@example.com" - ) + backchannel_auth_request_validator(requests_mock.last_request, scope=scope, login_hint="user@example.com") assert isinstance(bca_resp, BackChannelAuthenticationResponse) - assert 355 <= bca_resp.expires_in <= 360 + assert bca_resp.expires_in == 360 + + requests_mock.post(token_endpoint, json={"access_token": access_token, "token_type": "Bearer"}) + token_resp = bca_client.ciba(bca_resp) + assert isinstance(token_resp, BearerToken) -def test_backchannel_authentication_scope_list( + +def test_backchannel_authentication_scope_acr_values_as_list( requests_mock: RequestsMocker, backchannel_authentication_endpoint: str, bca_client: OAuth2Client, auth_req_id: str, backchannel_auth_request_validator: RequestValidatorType, ) -> None: - scope = ["openid", "email", "profile"] + scope = ("openid", "email", "profile") + acr_values = ("reinforced", "strong") + requests_mock.post( backchannel_authentication_endpoint, json={"auth_req_id": auth_req_id, "expires_in": 360, "interval": 3}, ) bca_resp = bca_client.backchannel_authentication_request( - scope=scope, login_hint="user@example.com" + scope=scope, acr_values=acr_values, login_hint="user@example.com" ) assert requests_mock.called_once backchannel_auth_request_validator( - requests_mock.last_request, scope=scope, login_hint="user@example.com" + requests_mock.last_request, scope=scope, acr_values=acr_values, login_hint="user@example.com" ) assert isinstance(bca_resp, BackChannelAuthenticationResponse) assert 355 <= bca_resp.expires_in <= 360 + with pytest.raises(ValueError, match="Unsupported `acr_values`"): + bca_client.backchannel_authentication_request(login_hint="user@example.net", acr_values=1.44) # type: ignore[arg-type] + + def test_backchannel_authentication_invalid_response( requests_mock: RequestsMocker, backchannel_authentication_endpoint: str, @@ -124,14 +133,10 @@ def test_backchannel_authentication_invalid_response( json={"foo": "bar"}, ) with pytest.raises(InvalidBackChannelAuthenticationResponse): - bca_client.backchannel_authentication_request( - scope=scope, login_hint="user@example.com" - ) + bca_client.backchannel_authentication_request(scope=scope, login_hint="user@example.com") assert requests_mock.called_once - backchannel_auth_request_validator( - requests_mock.last_request, scope=scope, login_hint="user@example.com" - ) + backchannel_auth_request_validator(requests_mock.last_request, scope=scope, login_hint="user@example.com") def test_backchannel_authentication_jwt( @@ -177,14 +182,10 @@ def test_backchannel_authentication_error( json={"error": "unauthorized_client"}, ) with pytest.raises(UnauthorizedClient): - bca_client.backchannel_authentication_request( - scope=scope, login_hint="user@example.com" - ) + bca_client.backchannel_authentication_request(scope=scope, login_hint="user@example.com") assert requests_mock.called_once - backchannel_auth_request_validator( - requests_mock.last_request, scope=scope, login_hint="user@example.com" - ) + backchannel_auth_request_validator(requests_mock.last_request, scope=scope, login_hint="user@example.com") def test_backchannel_authentication_invalid_error( @@ -200,14 +201,10 @@ def test_backchannel_authentication_invalid_error( json={"foo": "bar"}, ) with pytest.raises(InvalidBackChannelAuthenticationResponse): - bca_client.backchannel_authentication_request( - scope=scope, login_hint="user@example.com" - ) + bca_client.backchannel_authentication_request(scope=scope, login_hint="user@example.com") assert requests_mock.called_once - backchannel_auth_request_validator( - requests_mock.last_request, scope=scope, login_hint="user@example.com" - ) + backchannel_auth_request_validator(requests_mock.last_request, scope=scope, login_hint="user@example.com") def test_backchannel_authentication_not_json_error( @@ -223,14 +220,10 @@ def test_backchannel_authentication_not_json_error( text="Error!", ) with pytest.raises(InvalidBackChannelAuthenticationResponse): - bca_client.backchannel_authentication_request( - scope=scope, login_hint="user@example.com" - ) + bca_client.backchannel_authentication_request(scope=scope, login_hint="user@example.com") assert requests_mock.called_once - backchannel_auth_request_validator( - requests_mock.last_request, scope=scope, login_hint="user@example.com" - ) + backchannel_auth_request_validator(requests_mock.last_request, scope=scope, login_hint="user@example.com") def test_backchannel_authentication_missing_hint( @@ -249,7 +242,8 @@ def test_backchannel_authentication_missing_hint( def test_backchannel_authentication_invalid_scope(bca_client: OAuth2Client) -> None: with pytest.raises(ValueError): bca_client.backchannel_authentication_request( - scope=1.44, login_hint="user@example.net" # type: ignore[arg-type] + scope=1.44, # type: ignore[arg-type] + login_hint="user@example.net", ) @@ -290,9 +284,7 @@ def test_pooling_job( assert token.access_token == access_token -def test_missing_backchannel_authentication_endpoint( - token_endpoint: str, client_id: str, client_secret: str -) -> None: +def test_missing_backchannel_authentication_endpoint(token_endpoint: str, client_id: str, client_secret: str) -> None: client = OAuth2Client(token_endpoint, (client_id, client_secret)) with pytest.raises(AttributeError): client.backchannel_authentication_request(login_hint="username@foo.bar") diff --git a/tests/unit_tests/test_client.py b/tests/unit_tests/test_client.py index 7184ed2..74fec0b 100644 --- a/tests/unit_tests/test_client.py +++ b/tests/unit_tests/test_client.py @@ -1,9 +1,10 @@ from __future__ import annotations import secrets -from datetime import datetime, timedelta +from datetime import datetime, timedelta, timezone import pytest +import requests.auth from jwskate import Jwk, JwkSet, Jwt, KeyManagementAlgs from requests import HTTPError @@ -46,9 +47,7 @@ def test_private_key_jwt_auth(token_endpoint: str, client_id: str, private_jwk: assert client.auth.private_jwk == private_jwk -def test_client_secret_post_auth( - token_endpoint: str, client_id: str, client_secret: str -) -> None: +def test_client_secret_post_auth(token_endpoint: str, client_id: str, client_secret: str) -> None: """Passing a (client_id, client_secret) tuple as `auth` uses ClientSecretPost authentication.""" client = OAuth2Client(token_endpoint, auth=(client_id, client_secret)) @@ -57,9 +56,7 @@ def test_client_secret_post_auth( assert client.auth.client_secret == client_secret -def test_client_secret_basic_auth( - token_endpoint: str, client_id: str, client_secret: str -) -> None: +def test_client_secret_basic_auth(token_endpoint: str, client_id: str, client_secret: str) -> None: """Passing a (client_id, client_secret) tuple as `auth` and ClientSecretBasic as `default_auth_handler` uses ClientSecretBasic authentication.""" client = OAuth2Client(token_endpoint, auth=ClientSecretBasic(client_id, client_secret)) @@ -75,9 +72,7 @@ def test_invalid_auth(token_endpoint: str) -> None: with pytest.raises(ValueError): OAuth2Client(token_endpoint, auth=("client_id", "client_secret"), client_id="client_id") with pytest.raises(ValueError): - OAuth2Client( - token_endpoint, auth=("client_id", "client_secret"), client_secret="client_secret" - ) + OAuth2Client(token_endpoint, auth=("client_id", "client_secret"), client_secret="client_secret") with pytest.raises(ValueError): OAuth2Client(token_endpoint, ("client_id", "client_secret"), client_id="client_id") with pytest.raises(ValueError): @@ -290,6 +285,73 @@ def test_refresh_token_grant( ) +def test_refresh_token_with_bearer_instance_as_param( + requests_mock: RequestsMocker, + oauth2client: OAuth2Client, + token_endpoint: str, + access_token: str, + refresh_token: str, + client_id: str, + client_credential: None | str | Jwk, + public_jwk: Jwk, + client_auth_method_handler: type[BaseClientAuthenticationMethod], + refresh_token_grant_validator: RequestValidatorType, + public_app_auth_validator: RequestValidatorType, + client_secret_basic_auth_validator: RequestValidatorType, + client_secret_post_auth_validator: RequestValidatorType, + client_secret_jwt_auth_validator: RequestValidatorType, + private_key_jwt_auth_validator: RequestValidatorType, +) -> None: + new_access_token = secrets.token_urlsafe() + new_refresh_token = secrets.token_urlsafe() + requests_mock.post( + token_endpoint, + json={ + "access_token": new_access_token, + "refresh_token": new_refresh_token, + "token_type": "Bearer", + "expires_in": 3600, + }, + ) + token_resp = oauth2client.refresh_token(BearerToken(access_token=access_token, refresh_token=refresh_token)) + assert requests_mock.called_once + + assert not token_resp.is_expired() + assert token_resp.access_token == new_access_token + assert token_resp.refresh_token == new_refresh_token + + refresh_token_grant_validator(requests_mock.last_request, refresh_token=refresh_token) + + if client_auth_method_handler == PublicApp: + public_app_auth_validator(requests_mock.last_request, client_id=client_id) + elif client_auth_method_handler == ClientSecretPost: + client_secret_post_auth_validator( + requests_mock.last_request, + client_id=client_id, + client_secret=client_credential, + ) + elif client_auth_method_handler == ClientSecretBasic: + client_secret_basic_auth_validator( + requests_mock.last_request, + client_id=client_id, + client_secret=client_credential, + ) + elif client_auth_method_handler == ClientSecretJwt: + client_secret_jwt_auth_validator( + requests_mock.last_request, + client_id=client_id, + client_secret=client_credential, + endpoint=token_endpoint, + ) + elif client_auth_method_handler == PrivateKeyJwt: + private_key_jwt_auth_validator( + requests_mock.last_request, + client_id=client_id, + endpoint=token_endpoint, + public_jwk=public_jwk, + ) + + def test_ressource_owner_password_grant( requests_mock: RequestsMocker, oauth2client: OAuth2Client, @@ -320,11 +382,7 @@ def test_grants_with_invalid_response_objects_as_parameter(oauth2client: OAuth2C with pytest.raises(ValueError): oauth2client.ciba(BackChannelAuthenticationResponse(auth_req_id=None)) with pytest.raises(ValueError): - oauth2client.device_code( - DeviceAuthorizationResponse( - device_code=None, user_code="foo", verification_uri="bar" - ) - ) + oauth2client.device_code(DeviceAuthorizationResponse(device_code=None, user_code="foo", verification_uri="bar")) def test_device_code_grant( @@ -475,9 +533,7 @@ def test_token_exchange_invalid_tokens(oauth2client: OAuth2Client) -> None: oauth2client.token_exchange(subject_token="foo") with pytest.raises(TypeError): - oauth2client.token_exchange( - subject_token="foo", subject_token_type="access_token", actor_token="foo" - ) + oauth2client.token_exchange(subject_token="foo", subject_token_type="access_token", actor_token="foo") def test_userinfo( @@ -561,9 +617,7 @@ def test_from_discovery_document( ) -def test_from_discovery_document_missing_token_endpoint( - revocation_endpoint: str, client_id: str -) -> None: +def test_from_discovery_document_missing_token_endpoint(revocation_endpoint: str, client_id: str) -> None: """Invalid discovery documents raises an exception.""" with pytest.raises(ValueError): OAuth2Client.from_discovery_document( @@ -573,9 +627,7 @@ def test_from_discovery_document_missing_token_endpoint( ) -def test_from_discovery_document_token_endpoint_only( - token_endpoint: str, client_id: str -) -> None: +def test_from_discovery_document_token_endpoint_only(token_endpoint: str, client_id: str) -> None: """Invalid discovery documents raises an exception.""" client = OAuth2Client.from_discovery_document( {"token_endpoint": token_endpoint}, @@ -611,11 +663,9 @@ def test_from_discovery_endpoint( discovery_url = oidc_discovery_document_url(issuer) requests_mock.get(discovery_url, json=discovery_document) - requests_mock.get(jwks_uri, json=as_public_jwks) + requests_mock.get(jwks_uri, json=as_public_jwks.to_dict()) - client = OAuth2Client.from_discovery_endpoint( - discovery_url, issuer, auth=client_auth_method - ) + client = OAuth2Client.from_discovery_endpoint(discovery_url, issuer, auth=client_auth_method) assert requests_mock.request_history[0].url == discovery_url assert requests_mock.request_history[1].url == jwks_uri @@ -627,9 +677,7 @@ def test_from_discovery_endpoint( OAuth2Client.from_discovery_endpoint() -def test_invalid_token_response( - requests_mock: RequestsMocker, token_endpoint: str, client_id: str -) -> None: +def test_invalid_token_response(requests_mock: RequestsMocker, token_endpoint: str, client_id: str) -> None: """Token Endpoint error responses outside the standard raises an InvalidTokenResponse.""" client = OAuth2Client(token_endpoint, auth=client_id) requests_mock.post(token_endpoint, status_code=500, json={"confusing": "data"}) @@ -647,9 +695,7 @@ def test_invalid_token_response( assert requests_mock.called_once -def test_invalid_token_response_200( - requests_mock: RequestsMocker, token_endpoint: str, client_id: str -) -> None: +def test_invalid_token_response_200(requests_mock: RequestsMocker, token_endpoint: str, client_id: str) -> None: """Token Endpoint successful responses outside the standard raises an InvalidTokenResponse.""" client = OAuth2Client(token_endpoint, auth=client_id) requests_mock.post(token_endpoint, status_code=200, json={"confusing": "data"}) @@ -686,18 +732,14 @@ def test_revoke_access_token( ) -> None: """.revoke_access_token() sends a Revocation request to the Revocation Endpoint, with token_type_hint=access_token.""" - client = OAuth2Client( - token_endpoint, revocation_endpoint=revocation_endpoint, auth=client_auth_method - ) + client = OAuth2Client(token_endpoint, revocation_endpoint=revocation_endpoint, auth=client_auth_method) requests_mock.post(revocation_endpoint) assert client.revoke_access_token(access_token) is True assert requests_mock.called_once - revocation_request_validator( - requests_mock.last_request, token=access_token, type_hint="access_token" - ) + revocation_request_validator(requests_mock.last_request, token=access_token, type_hint="access_token") if client_auth_method_handler == PublicApp: public_app_auth_validator(requests_mock.last_request, client_id=client_id) @@ -748,16 +790,12 @@ def test_revoke_refresh_token( ) -> None: """.revoke_refresh_token() sends a Revocation request to the Revocation Endpoint, with token_type_hint=refresh_token.""" - client = OAuth2Client( - token_endpoint, revocation_endpoint=revocation_endpoint, auth=client_auth_method - ) + client = OAuth2Client(token_endpoint, revocation_endpoint=revocation_endpoint, auth=client_auth_method) requests_mock.post(revocation_endpoint) assert client.revoke_refresh_token(refresh_token) is True assert requests_mock.called_once - revocation_request_validator( - requests_mock.last_request, token=refresh_token, type_hint="refresh_token" - ) + revocation_request_validator(requests_mock.last_request, token=refresh_token, type_hint="refresh_token") if client_auth_method_handler == PublicApp: public_app_auth_validator(requests_mock.last_request, client_id=client_id) elif client_auth_method_handler == ClientSecretPost: @@ -799,9 +837,7 @@ def test_revoke_refresh_token_with_bearer_token_as_param( ) -> None: """.revoke_refresh_token() sends a Revocation request to the Revocation Endpoint, with token_type_hint=refresh_token.""" - client = OAuth2Client( - token_endpoint, revocation_endpoint=revocation_endpoint, auth=client_auth_method - ) + client = OAuth2Client(token_endpoint, revocation_endpoint=revocation_endpoint, auth=client_auth_method) bearer = BearerToken(access_token, refresh_token=refresh_token) requests_mock.post(revocation_endpoint) assert client.revoke_refresh_token(bearer) is True @@ -836,9 +872,7 @@ def test_revoke_token( revocation_request_validator: RequestValidatorType, ) -> None: """.revoke_token() sends a Revocation request to the Revocation Endpoint.""" - client = OAuth2Client( - token_endpoint, revocation_endpoint=revocation_endpoint, auth=client_auth_method - ) + client = OAuth2Client(token_endpoint, revocation_endpoint=revocation_endpoint, auth=client_auth_method) requests_mock.post(revocation_endpoint, status_code=200) assert client.revoke_token(refresh_token) is True @@ -887,9 +921,7 @@ def test_revoke_token_with_bearer_token_as_param( ) -> None: """If a BearerToken is supplied and token_token_type_hint=refresh_token, take the refresh token from the BearerToken.""" - client = OAuth2Client( - token_endpoint, revocation_endpoint=revocation_endpoint, auth=client_auth_method - ) + client = OAuth2Client(token_endpoint, revocation_endpoint=revocation_endpoint, auth=client_auth_method) bearer = BearerToken(access_token, refresh_token=refresh_token) requests_mock.post(revocation_endpoint, status_code=200) @@ -922,9 +954,7 @@ def test_revoke_token_error( ) -> None: """.revoke_token() sends a Revocation request to the Revocation Endpoint, with token_type_hint=access_token.""" - client = OAuth2Client( - token_endpoint, revocation_endpoint=revocation_endpoint, auth=client_auth_method - ) + client = OAuth2Client(token_endpoint, revocation_endpoint=revocation_endpoint, auth=client_auth_method) requests_mock.post(revocation_endpoint, status_code=400, json={"error": "server_error"}) with pytest.raises(ServerError): @@ -972,9 +1002,7 @@ def test_revoke_token_error_non_standard( ) -> None: """.revoke_token() sends a Revocation request to the Revocation Endpoint, with token_type_hint=access_token.""" - client = OAuth2Client( - token_endpoint, revocation_endpoint=revocation_endpoint, auth=client_auth_method - ) + client = OAuth2Client(token_endpoint, revocation_endpoint=revocation_endpoint, auth=client_auth_method) requests_mock.post(revocation_endpoint, status_code=400, text="Error") assert client.revoke_token(refresh_token) is False @@ -1002,7 +1030,8 @@ def test_server_jwks( ) -> None: """Use OAuth2Client as a context manager to automatically get the public JWKS from its JWKS URI.""" - requests_mock.get(jwks_uri, json=dict(server_public_jwks)) + assert not oauth2client.authorization_server_jwks + requests_mock.get(jwks_uri, json=server_public_jwks.to_dict()) with oauth2client as client: assert client.authorization_server_jwks == server_public_jwks assert requests_mock.called_once @@ -1015,9 +1044,7 @@ def test_server_jwks_no_jwks_uri(token_endpoint: str) -> None: assert client.update_authorization_server_public_keys() -def test_server_jwks_not_json( - requests_mock: RequestsMocker, token_endpoint: str, jwks_uri: str -) -> None: +def test_server_jwks_not_json(requests_mock: RequestsMocker, token_endpoint: str, jwks_uri: str) -> None: """If JWKS URI is not known, get_public_jwks() raises an exception.""" requests_mock.get(jwks_uri, text="Hello World!") client = OAuth2Client(token_endpoint=token_endpoint, jwks_uri=jwks_uri, auth=("foo", "bar")) @@ -1026,9 +1053,7 @@ def test_server_jwks_not_json( assert requests_mock.called_once -def test_server_jwks_invalid_doc( - requests_mock: RequestsMocker, token_endpoint: str, jwks_uri: str -) -> None: +def test_server_jwks_invalid_doc(requests_mock: RequestsMocker, token_endpoint: str, jwks_uri: str) -> None: """If JWKS URI is an invalid document, get_public_jwks() raises an exception.""" requests_mock.get(jwks_uri, json={"foo": "bar"}) client = OAuth2Client(token_endpoint=token_endpoint, jwks_uri=jwks_uri, auth=("foo", "bar")) @@ -1038,22 +1063,10 @@ def test_server_jwks_invalid_doc( def test_get_token_type() -> None: - assert ( - OAuth2Client.get_token_type(token_type="access_token") - == "urn:ietf:params:oauth:token-type:access_token" - ) - assert ( - OAuth2Client.get_token_type(token_type="refresh_token") - == "urn:ietf:params:oauth:token-type:refresh_token" - ) - assert ( - OAuth2Client.get_token_type(token_type="id_token") - == "urn:ietf:params:oauth:token-type:id_token" - ) - assert ( - OAuth2Client.get_token_type(token_type="saml2") - == "urn:ietf:params:oauth:token-type:saml2" - ) + assert OAuth2Client.get_token_type(token_type="access_token") == "urn:ietf:params:oauth:token-type:access_token" + assert OAuth2Client.get_token_type(token_type="refresh_token") == "urn:ietf:params:oauth:token-type:refresh_token" + assert OAuth2Client.get_token_type(token_type="id_token") == "urn:ietf:params:oauth:token-type:id_token" + assert OAuth2Client.get_token_type(token_type="saml2") == "urn:ietf:params:oauth:token-type:saml2" with pytest.raises(ValueError): assert OAuth2Client.get_token_type(token="token") @@ -1063,9 +1076,7 @@ def test_get_token_type() -> None: == "urn:ietf:params:oauth:token-type:access_token" ) assert ( - OAuth2Client.get_token_type( - token=BearerToken("access_token", refresh_token="refresh_token") - ) + OAuth2Client.get_token_type(token=BearerToken("access_token", refresh_token="refresh_token")) == "urn:ietf:params:oauth:token-type:access_token" ) assert ( @@ -1077,9 +1088,7 @@ def test_get_token_type() -> None: ) with pytest.raises(ValueError): - OAuth2Client.get_token_type( - token=BearerToken("access_token"), token_type="refresh_token" - ) + OAuth2Client.get_token_type(token=BearerToken("access_token"), token_type="refresh_token") with pytest.raises(ValueError): OAuth2Client.get_token_type() @@ -1178,9 +1187,7 @@ def test_introspection_error( introspection_endpoint: str, introspection_request_validator: RequestValidatorType, ) -> None: - requests_mock.post( - introspection_endpoint, status_code=400, json={"error": "unauthorized_client"} - ) + requests_mock.post(introspection_endpoint, status_code=400, json={"error": "unauthorized_client"}) with pytest.raises(UnauthorizedClient): oauth2client.introspect_token("access_token") @@ -1234,16 +1241,21 @@ def test_introspection_with_bearer_token_as_param( requests_mock.post(introspection_endpoint, status_code=200, json={"active": False}) bearer = BearerToken(access_token, refresh_token=refresh_token) assert oauth2client.introspect_token(bearer, "refresh_token") + assert requests_mock.called_once + introspection_request_validator(requests_mock.last_request, token=refresh_token, type_hint="refresh_token") + requests_mock.reset() + assert oauth2client.introspect_token(bearer, token_type_hint="access_token") assert requests_mock.called_once - introspection_request_validator( - requests_mock.last_request, token=refresh_token, type_hint="refresh_token" - ) + introspection_request_validator(requests_mock.last_request, token=access_token, type_hint="access_token") bearer_no_refresh = BearerToken(access_token, refresh_token=None) with pytest.raises(ValueError): oauth2client.introspect_token(bearer_no_refresh, "refresh_token") + with pytest.raises(ValueError, match="Invalid `token_type_hint`"): + oauth2client.introspect_token(bearer, token_type_hint="unknown_token") + def test_ciba( requests_mock: RequestsMocker, @@ -1277,7 +1289,7 @@ def test_pushed_authorization_request( razr = oauth2client.pushed_authorization_request(authorization_request) assert isinstance(razr, RequestUriParameterAuthorizationRequest) assert razr.request_uri == request_uri - assert isinstance(razr.expires_at, datetime) and datetime.now() - timedelta( + assert isinstance(razr.expires_at, datetime) and datetime.now(tz=timezone.utc) - timedelta( seconds=2 ) < razr.expires_at - timedelta(seconds=expires_in) @@ -1288,9 +1300,7 @@ def test_pushed_authorization_request_error( pushed_authorization_request_endpoint: str, authorization_request: AuthorizationRequest, ) -> None: - requests_mock.post( - pushed_authorization_request_endpoint, json={"error": "server_error"}, status_code=500 - ) + requests_mock.post(pushed_authorization_request_endpoint, json={"error": "server_error"}, status_code=500) with pytest.raises(ServerError): oauth2client.pushed_authorization_request(authorization_request) @@ -1301,9 +1311,7 @@ def test_pushed_authorization_request_error( oauth2client.pushed_authorization_request(authorization_request) -def test_jwt_bearer_grant( - requests_mock: RequestsMocker, oauth2client: OAuth2Client, token_endpoint: str -) -> None: +def test_jwt_bearer_grant(requests_mock: RequestsMocker, oauth2client: OAuth2Client, token_endpoint: str) -> None: key = Jwk.generate_for_kty("EC", alg="ES256") assertion = Jwt.sign({"iat": 1661759343, "exp": 1661759403, "sub": "some_user_id"}, key) scope = "my_scope" @@ -1333,7 +1341,7 @@ def test_authorization_request(oauth2client: OAuth2Client, authorization_endpoin assert isinstance(auth_req, AuthorizationRequest) assert auth_req.authorization_endpoint == authorization_endpoint assert auth_req.response_type == "code" - assert auth_req.scope == scope.split() + assert auth_req.scope == tuple(scope.split()) with pytest.raises(ValueError): oauth2client.authorization_request(response_type="token") @@ -1347,29 +1355,24 @@ def test_authorization_request(oauth2client: OAuth2Client, authorization_endpoin def test_custom_token_type(requests_mock: RequestsMocker) -> None: - class WeirdBearerToken(BearerToken): - TOKEN_TYPE = "BearerToken" - - class WeirdOAuth2Client(OAuth2Client): - token_class = WeirdBearerToken + class CustomBearerToken(BearerToken): + TOKEN_TYPE = "CustomBearerToken" TOKEN_ENDPOINT = "https://as.local/token" - client = WeirdOAuth2Client(TOKEN_ENDPOINT, ("client_id", "client_secret")) + client = OAuth2Client(TOKEN_ENDPOINT, ("client_id", "client_secret"), bearer_token_class=CustomBearerToken) requests_mock.post( TOKEN_ENDPOINT, - json={"access_token": "access_token", "token_type": "BearerToken"}, + json={"access_token": "access_token", "token_type": "CustomBearerToken"}, ) token = client.client_credentials() - assert isinstance(token, WeirdBearerToken) + assert isinstance(token, CustomBearerToken) def test_client_jwks() -> None: private_key = Jwk.generate_for_alg(KeyManagementAlgs.RSA_OAEP_256).with_kid_thumbprint() - id_token_decryption_key = Jwk.generate_for_alg( - KeyManagementAlgs.ECDH_ES_A256KW - ).with_kid_thumbprint() + id_token_decryption_key = Jwk.generate_for_alg(KeyManagementAlgs.ECDH_ES_A256KW).with_kid_thumbprint() client = OAuth2Client( authorization_endpoint="https://as.local/authorize", token_endpoint="https://as.local/token", @@ -1392,3 +1395,50 @@ def test_issuer_identification_missing_issuer() -> None: client_id="my_client_id", authorization_response_iss_parameter_supported=True, ) + + +def test_client_authorization_server_jwks() -> None: + jwks = Jwk.generate(alg="ES256").public_jwk().as_jwks() + assert ( + OAuth2Client( + "https://token.endpoint", client_id="client_id", authorization_server_jwks=jwks + ).authorization_server_jwks + is jwks + ) + assert ( + OAuth2Client( + "https://token.endpoint", client_id="client_id", authorization_server_jwks=jwks.to_dict() + ).authorization_server_jwks + == jwks + ) + + +def test_client_id_token_decryption_key() -> None: + decryption_key = Jwk.generate(alg=KeyManagementAlgs.ECDH_ES_A256KW) + assert ( + OAuth2Client( + "https://token.endpoint", client_id="client_id", id_token_decryption_key=decryption_key + ).id_token_decryption_key + is decryption_key + ) + assert ( + OAuth2Client( + "https://token.endpoint", client_id="client_id", id_token_decryption_key=decryption_key.to_dict() + ).id_token_decryption_key + == decryption_key + ) + + with pytest.raises(ValueError, match="no decryption algorithm is defined"): + assert OAuth2Client( + "https://token.endpoint", client_id="client_id", id_token_decryption_key=decryption_key.minimize() + ) + + +def test_client_custom_auth_method() -> None: + class CustomAuthHandler(requests.auth.AuthBase): + def __call__(self, request: requests.PreparedRequest) -> requests.PreparedRequest: + request.headers["Super-Secure"] = "true" + return request + + with pytest.raises(AttributeError, match="custom authentication method without client_id"): + OAuth2Client("https://token.endpoint", auth=CustomAuthHandler()).client_id diff --git a/tests/unit_tests/test_client_authentication.py b/tests/unit_tests/test_client_authentication.py index d1e0652..af557a2 100644 --- a/tests/unit_tests/test_client_authentication.py +++ b/tests/unit_tests/test_client_authentication.py @@ -30,9 +30,7 @@ def test_client_secret_post( assert client.client_credentials() assert requests_mock.called_once - client_secret_post_auth_validator( - requests_mock.last_request, client_id=client_id, client_secret=client_secret - ) + client_secret_post_auth_validator(requests_mock.last_request, client_id=client_id, client_secret=client_secret) def test_client_secret_basic( @@ -52,9 +50,7 @@ def test_client_secret_basic( assert client.client_credentials() assert requests_mock.called_once - client_secret_basic_auth_validator( - requests_mock.last_request, client_id=client_id, client_secret=client_secret - ) + client_secret_basic_auth_validator(requests_mock.last_request, client_id=client_id, client_secret=client_secret) def test_private_key_jwt( @@ -157,9 +153,7 @@ def test_public_client( public_app_auth_validator(requests_mock.last_request, client_id=client_id) -def test_invalid_request( - requests_mock: RequestsMocker, client_id: str, client_secret: str -) -> None: +def test_invalid_request(requests_mock: RequestsMocker, client_id: str, client_secret: str) -> None: requests_mock.get(ANY) with pytest.raises(RuntimeError): requests.get("http://localhost", auth=ClientSecretBasic(client_id, client_secret)) @@ -178,9 +172,7 @@ def test_private_key_jwt_missing_kid(client_id: str, private_jwk: Jwk) -> None: PrivateKeyJwt(client_id=client_id, private_jwk=private_jwk_without_kid) -def test_init_auth( - token_endpoint: str, client_id: str, client_secret: str, private_jwk: Jwk -) -> None: +def test_init_auth(token_endpoint: str, client_id: str, client_secret: str, private_jwk: Jwk) -> None: csp_client = OAuth2Client(token_endpoint, (client_id, client_secret)) assert isinstance(csp_client.auth, ClientSecretPost) assert csp_client.auth.client_id == client_id diff --git a/tests/unit_tests/test_device_authorization.py b/tests/unit_tests/test_device_authorization.py index 7d9b6ca..9ae0c3d 100644 --- a/tests/unit_tests/test_device_authorization.py +++ b/tests/unit_tests/test_device_authorization.py @@ -1,4 +1,4 @@ -from datetime import datetime +from datetime import datetime, timezone import pytest @@ -36,7 +36,7 @@ def test_device_authorization_response( assert response.verification_uri == verification_uri assert response.verification_uri_complete == verification_uri_complete assert isinstance(response.expires_at, datetime) - assert response.expires_at > datetime.now() + assert response.expires_at > datetime.now(tz=timezone.utc) assert response.interval == 10 @@ -46,7 +46,7 @@ def test_device_authorization_response_expires_at( verification_uri: str, verification_uri_complete: str, ) -> None: - expires_at = datetime(year=2021, month=1, day=1, hour=0, minute=0, second=0) + expires_at = datetime(year=2021, month=1, day=1, hour=0, minute=0, second=0, tzinfo=timezone.utc) response = DeviceAuthorizationResponse( device_code=device_code, user_code=user_code, @@ -135,9 +135,7 @@ def test_device_authorization_client( device_authorization_client.authorize_device() assert requests_mock.called_once - client_secret_post_auth_validator( - requests_mock.last_request, client_id=client_id, client_secret=client_secret - ) + client_secret_post_auth_validator(requests_mock.last_request, client_id=client_id, client_secret=client_secret) def test_device_authorization_client_error( @@ -159,9 +157,7 @@ def test_device_authorization_client_error( with pytest.raises(UnauthorizedClient): device_authorization_client.authorize_device() assert requests_mock.called_once - client_secret_post_auth_validator( - requests_mock.last_request, client_id=client_id, client_secret=client_secret - ) + client_secret_post_auth_validator(requests_mock.last_request, client_id=client_id, client_secret=client_secret) def test_device_authorization_invalid_errors( @@ -183,9 +179,7 @@ def test_device_authorization_invalid_errors( with pytest.raises(DeviceAuthorizationError): device_authorization_client.authorize_device() assert requests_mock.called_once - client_secret_post_auth_validator( - requests_mock.last_request, client_id=client_id, client_secret=client_secret - ) + client_secret_post_auth_validator(requests_mock.last_request, client_id=client_id, client_secret=client_secret) requests_mock.reset_mock() requests_mock.post( @@ -199,9 +193,7 @@ def test_device_authorization_invalid_errors( with pytest.raises(InvalidDeviceAuthorizationResponse): device_authorization_client.authorize_device() assert requests_mock.called_once - client_secret_post_auth_validator( - requests_mock.last_request, client_id=client_id, client_secret=client_secret - ) + client_secret_post_auth_validator(requests_mock.last_request, client_id=client_id, client_secret=client_secret) def test_device_authorization_pooling_job( @@ -242,9 +234,7 @@ def test_device_authorization_pooling_job( assert token.access_token == access_token -def test_no_device_authorization_endpoint( - token_endpoint: str, client_id: str, client_secret: str -) -> None: +def test_no_device_authorization_endpoint(token_endpoint: str, client_id: str, client_secret: str) -> None: client = OAuth2Client(token_endpoint, (client_id, client_secret)) with pytest.raises(AttributeError): client.authorize_device() diff --git a/tests/unit_tests/test_discovery.py b/tests/unit_tests/test_discovery.py index d2e14bc..d057715 100644 --- a/tests/unit_tests/test_discovery.py +++ b/tests/unit_tests/test_discovery.py @@ -6,23 +6,11 @@ def test_well_known_uri() -> None: - assert ( - well_known_uri("http://www.example.com", "example") - == "http://www.example.com/.well-known/example" - ) - assert ( - well_known_uri("http://www.example.com/", "example") - == "http://www.example.com/.well-known/example" - ) + assert well_known_uri("http://www.example.com", "example") == "http://www.example.com/.well-known/example" + assert well_known_uri("http://www.example.com/", "example") == "http://www.example.com/.well-known/example" - assert ( - well_known_uri("http://www.example.com/foo", "example") - == "http://www.example.com/.well-known/foo/example" - ) - assert ( - well_known_uri("http://www.example.com/foo/", "example") - == "http://www.example.com/.well-known/foo/example" - ) + assert well_known_uri("http://www.example.com/foo", "example") == "http://www.example.com/.well-known/foo/example" + assert well_known_uri("http://www.example.com/foo/", "example") == "http://www.example.com/.well-known/foo/example" assert ( well_known_uri("http://www.example.com/foo/bar", "example") @@ -35,10 +23,7 @@ def test_well_known_uri() -> None: def test_oidc_discovery() -> None: - assert ( - oidc_discovery_document_url("https://issuer.com") - == "https://issuer.com/.well-known/openid-configuration" - ) + assert oidc_discovery_document_url("https://issuer.com") == "https://issuer.com/.well-known/openid-configuration" assert ( oidc_discovery_document_url("https://issuer.com/oidc") == "https://issuer.com/oidc/.well-known/openid-configuration" diff --git a/tests/unit_tests/test_oidc.py b/tests/unit_tests/test_oidc.py index 39816c0..50c694e 100644 --- a/tests/unit_tests/test_oidc.py +++ b/tests/unit_tests/test_oidc.py @@ -52,17 +52,19 @@ "07UgYISe6yaAzmTIBr_f2vchFCIs6bAGk1-36iEH00fq4B3eBih5g0r_kEPHpuYLqbXOq7gDBVpr", "ZPaPdOYbQ2dUGsQZHaSIcIveQMwWh4yG8lMT9Cfa_cSKSO8KGjx4rqI4zwmAfYJ6bPIxZWeUwvUn", ), + ( + {"alg": "EdDSA", "crv": "Ed25519"}, + "p2LHG4H-8pYDc0hyVOo3iIHvZJUqe9tbj3jESOuXbkY", + "E9z1C-c0Az4eTEzE0Nm3OQ3BS2BhMgxuP7x5JAQj1_4", + "aVrO6_zIGuPg0pvBhlmB9jnpmFoY6MXEt1nJeHp1pmI", + ) ), ) -def test_validate_id_token( - kwargs: dict[str, str], at_hash: str, c_hash: str, s_hash: str -) -> None: +def test_validate_id_token(kwargs: dict[str, str], at_hash: str, c_hash: str, s_hash: str) -> None: signing_key = jwskate.Jwk.generate(**kwargs).with_kid_thumbprint() jwks = signing_key.public_jwk().minimize().as_jwks() client_id = "s6BhdRkqt3" - access_token = ( - "YmJiZTAwYmYtMzgyOC00NzhkLTkyOTItNjJjNDM3MGYzOWIy9sFhvH8K_x8UIHj1osisS57f5DduL" - ) + access_token = "YmJiZTAwYmYtMzgyOC00NzhkLTkyOTItNjJjNDM3MGYzOWIy9sFhvH8K_x8UIHj1osisS57f5DduL" code = "Qcb0Orv1zh30vL1MPRsbm-diHiMwcLyZvn1arpZv-Jxf_11jnpEX3Tgfvk" state = "qu2pNLwFWBjakH2x4OxivEVtjKiM27SHrPdY3McJN4g" @@ -82,18 +84,23 @@ def test_validate_id_token( }, signing_key, ) - assert id_token == BearerToken( - access_token=access_token, - expires_in=60, - id_token=str(id_token), - ).validate_id_token( - client=OAuth2Client( - "https://myas.local/token", - client_id=client_id, - authorization_server_jwks=jwks, - id_token_signed_response_alg=kwargs["alg"], - ), - azr=AuthorizationResponse(code=code, nonce=nonce, max_age=0, state=state), + assert ( + BearerToken( + access_token=access_token, + expires_in=60, + id_token=str(id_token), + ) + .validate_id_token( + client=OAuth2Client( + "https://myas.local/token", + client_id=client_id, + authorization_server_jwks=jwks, + id_token_signed_response_alg=kwargs["alg"], + ), + azr=AuthorizationResponse(code=code, nonce=nonce, max_age=0, state=state), + ) + .id_token + == id_token ) @@ -105,9 +112,7 @@ def test_invalid_id_token(token_endpoint: str) -> None: ) with pytest.raises(InvalidIdToken): - BearerToken( - access_token="an_access_token", expires_in=60, id_token="foo" - ).validate_id_token( + BearerToken(access_token="an_access_token", expires_in=60, id_token="foo").validate_id_token( client=OAuth2Client(token_endpoint, client_id="client_id"), azr=AuthorizationResponse(code="code"), ) @@ -126,9 +131,7 @@ def test_invalid_id_token(token_endpoint: str) -> None: } with pytest.raises(InvalidIdToken, match="should be encrypted"): - BearerToken( - access_token="an_access_token", id_token=Jwt.sign(claims, sig_jwk).value - ).validate_id_token( + BearerToken(access_token="an_access_token", id_token=Jwt.sign(claims, sig_jwk).value).validate_id_token( client=OAuth2Client( token_endpoint, client_id=client_id, @@ -171,9 +174,7 @@ def test_invalid_id_token(token_endpoint: str) -> None: ) with pytest.raises(MismatchingIssuer): - BearerToken( - access_token="an_access_token", id_token=Jwt.sign(claims, sig_jwk).value - ).validate_id_token( + BearerToken(access_token="an_access_token", id_token=Jwt.sign(claims, sig_jwk).value).validate_id_token( client=OAuth2Client(token_endpoint, client_id=client_id), azr=AuthorizationResponse(code="code", issuer="https://a.different.issuer"), ) @@ -326,37 +327,36 @@ def test_invalid_id_token(token_endpoint: str) -> None: sig_jwk, ).value, ).validate_id_token( - client=OAuth2Client( - token_endpoint, client_id=client_id, authorization_server_jwks=None - ), + client=OAuth2Client(token_endpoint, client_id=client_id, authorization_server_jwks=None), azr=AuthorizationResponse(code="code", issuer=issuer), ) with pytest.raises(InvalidIdToken, match="does not contain a Key ID"): - sig_jwk_no_kid = Jwk.generate(alg=SignatureAlgs.RS256) BearerToken( access_token="an_access_token", - id_token=Jwt.sign( - { + id_token=Jwt.sign_arbitrary( + claims={ "iss": issuer, "aud": client_id, "iat": Jwt.timestamp(), "exp": Jwt.timestamp(60), "azp": client_id, }, - sig_jwk_no_kid, + headers={"alg": "RS256"}, + key=sig_jwk, ).value, ).validate_id_token( client=OAuth2Client( token_endpoint, client_id=client_id, - authorization_server_jwks=sig_jwk_no_kid.public_jwk().as_jwks(), + authorization_server_jwks=sig_jwk.public_jwk().as_jwks(), ), azr=AuthorizationResponse(code="code", issuer=issuer), ) with pytest.raises( - InvalidIdToken, match="Key ID is not part of the Authorization Server JWKS" + InvalidIdToken, + match=f"Key ID '{sig_jwk.kid}' is not part of the Authorization Server JWKS", ): BearerToken( access_token="an_access_token", @@ -374,7 +374,7 @@ def test_invalid_id_token(token_endpoint: str) -> None: client=OAuth2Client( token_endpoint, client_id=client_id, - authorization_server_jwks=Jwk.generate(alg=SignatureAlgs.RS256) + authorization_server_jwks=Jwk.generate(alg=SignatureAlgs.ES256) .with_kid_thumbprint() .public_jwk() .as_jwks(), @@ -500,9 +500,7 @@ def test_invalid_id_token(token_endpoint: str) -> None: ) # ID Token signed with alg not supported by verification key - with pytest.raises( - InvalidIdToken, match="algorithm is not supported by the verification key" - ): + with pytest.raises(InvalidIdToken, match="algorithm is not supported by the verification key"): BearerToken( access_token="an_access_token", id_token=Jwt.sign_arbitrary( @@ -595,9 +593,7 @@ def test_id_token_signed_with_client_secret(token_endpoint: str) -> None: BearerToken( access_token="access_token", - id_token=Jwt.sign( - claims, key=Jwk.from_cryptography_key(client_secret.encode()), alg=alg - ).value, + id_token=Jwt.sign(claims, key=Jwk.from_cryptography_key(client_secret.encode()), alg=alg).value, ).validate_id_token( client=OAuth2Client( token_endpoint, diff --git a/tests/unit_tests/test_pkce.py b/tests/unit_tests/test_pkce.py index 5594ea6..d805865 100644 --- a/tests/unit_tests/test_pkce.py +++ b/tests/unit_tests/test_pkce.py @@ -17,12 +17,7 @@ def test_generate_code_verifier_and_challenge() -> None: assert len(challenge) == 43 assert set(verifier).issubset(set(string.ascii_letters + string.digits + "_-")) - assert ( - base64.urlsafe_b64encode(hashlib.sha256(verifier.encode()).digest()) - .decode() - .rstrip("=") - == challenge - ) + assert base64.urlsafe_b64encode(hashlib.sha256(verifier.encode()).digest()).decode().rstrip("=") == challenge assert PkceUtils.validate_code_verifier(verifier, challenge) @@ -45,7 +40,5 @@ def test_invalid_verifier() -> None: def test_verifier_bytes() -> None: - challenge = PkceUtils.derive_challenge( - b"abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMOPQRSTUVWXYZ1234567890" - ) + challenge = PkceUtils.derive_challenge(b"abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMOPQRSTUVWXYZ1234567890") assert challenge == "FYKCx6MubiaOxWp8-ciyDkkkOapyAjR9sxikqOSXLdw" diff --git a/tests/unit_tests/test_tokens.py b/tests/unit_tests/test_tokens.py index adbbe96..e229379 100644 --- a/tests/unit_tests/test_tokens.py +++ b/tests/unit_tests/test_tokens.py @@ -8,6 +8,7 @@ InvalidJwt, InvalidSignature, Jwk, + SignatureAlgs, SignedJwt, ) @@ -25,15 +26,14 @@ def test_bearer_token_simple() -> None: token = BearerToken(access_token="foo") - assert "access_token" in token - assert "refresh_token" not in token - assert "scope" not in token - assert "token_type" in token - assert "expires_in" not in token - assert "foo" not in token - assert token.expires_in is None - assert token.expires_at is None + assert token.access_token == "foo" + assert token.refresh_token is None + assert token.scope is None assert token.token_type == "Bearer" + assert token.expires_at is None + assert token.expires_in is None + with pytest.raises(AttributeError): + token.foo assert token.as_dict() == { "access_token": "foo", @@ -43,10 +43,11 @@ def test_bearer_token_simple() -> None: assert str(token) == "foo" assert repr(token) - assert token == "foo" - assert token != 1.2 + assert str(token) == "foo" + assert token != 1.2 # type: ignore[comparison-overlap] +@freeze_time("2021-08-17 12:50:18") def test_bearer_token_complete() -> None: id_token = IdToken.sign( { @@ -55,7 +56,7 @@ def test_bearer_token_complete() -> None: "exp": IdToken.timestamp(60), "sub": "myuserid", }, - Jwk.generate_for_alg("RS256"), + Jwk.generate_for_alg(SignatureAlgs.RS256), ) token = BearerToken( access_token="foo", @@ -65,48 +66,47 @@ def test_bearer_token_complete() -> None: custom_attr="custom_value", id_token=str(id_token), ) - assert "access_token" in token - assert "refresh_token" in token - assert "scope" in token - assert "token_type" in token - assert "expires_in" in token - assert "foo" not in token - assert "custom_attr" in token - assert "id_token" in token - assert token.expires_in is not None - assert token.expires_at is not None + assert token.access_token == "foo" + assert token.refresh_token == "refresh_token" + assert token.scope == "myscope1 myscope2" assert token.token_type == "Bearer" + assert token.expires_in == 180 + assert token.custom_attr == "custom_value" + assert token.id_token == id_token + assert token.expires_at == datetime(year=2021, month=8, day=17, hour=12, minute=53, second=18, tzinfo=timezone.utc) + with pytest.raises(AttributeError): + token.foo assert token.as_dict() == { "access_token": "foo", "token_type": "Bearer", "refresh_token": "refresh_token", - "expires_in": token.expires_in, # TODO: enhance + "expires_in": 180, "scope": "myscope1 myscope2", "custom_attr": "custom_value", "id_token": str(id_token), } - assert token.expires_in <= 180 - assert token.custom_attr == "custom_value" - - with pytest.raises(AttributeError): - token.foo - assert str(token) == "foo" assert repr(token) @freeze_time("2021-08-17 12:50:18") def test_nearly_expired_token() -> None: - token = BearerToken(access_token="foo", expires_at=datetime(2021, 8, 17, 12, 50, 20)) + token = BearerToken( + access_token="foo", + expires_at=datetime(year=2021, month=8, day=17, hour=12, minute=50, second=20, tzinfo=timezone.utc), + ) assert not token.is_expired() assert token.is_expired(3) @freeze_time("2021-08-17 12:50:21") def test_recently_expired_token() -> None: - token = BearerToken(access_token="foo", expires_at=datetime(2021, 8, 17, 12, 50, 20)) + token = BearerToken( + access_token="foo", + expires_at=datetime(year=2021, month=8, day=17, hour=12, minute=50, second=20, tzinfo=timezone.utc), + ) assert token.is_expired() assert token.is_expired(3) assert not token.is_expired(-3) @@ -156,15 +156,9 @@ def test_jwt_iat_exp_nbf() -> None: } assert jwt.verify_signature(public_jwk, alg="RS256") - assert jwt.issued_at == datetime( - year=2021, month=8, day=19, hour=14, minute=55, second=28, tzinfo=timezone.utc - ) - assert jwt.expires_at == datetime( - year=2021, month=8, day=19, hour=14, minute=56, second=28, tzinfo=timezone.utc - ) - assert jwt.not_before == datetime( - year=2021, month=8, day=19, hour=14, minute=54, second=28, tzinfo=timezone.utc - ) + assert jwt.issued_at == datetime(year=2021, month=8, day=19, hour=14, minute=55, second=28, tzinfo=timezone.utc) + assert jwt.expires_at == datetime(year=2021, month=8, day=19, hour=14, minute=56, second=28, tzinfo=timezone.utc) + assert jwt.not_before == datetime(year=2021, month=8, day=19, hour=14, minute=54, second=28, tzinfo=timezone.utc) assert jwt.iat == 1629384928 assert jwt.exp == 1629384988 @@ -202,9 +196,7 @@ def test_id_token() -> None: ) with pytest.raises(ExpiredJwt): - id_token.validate( - public_jwk, issuer=issuer, audience=audience, nonce=nonce, check_exp=True - ) + id_token.validate(public_jwk, issuer=issuer, audience=audience, nonce=nonce, check_exp=True) assert id_token.alg == "RS256" assert id_token.kid == "my_key" @@ -232,33 +224,24 @@ def test_invalid_jwt() -> None: id_token = IdToken(ID_TOKEN) modified_id_token = IdToken( - ID_TOKEN[:-4] # strips a few chars from the signature - + "abcd" # replace them with arbitrary data + ID_TOKEN[:-4] + "abcd" # strips a few chars from the signature # replace them with arbitrary data ) # invalid signature with pytest.raises(InvalidSignature): - modified_id_token.validate( - public_jwk, issuer=issuer, audience=audience, nonce=nonce, check_exp=False - ) + modified_id_token.validate(public_jwk, issuer=issuer, audience=audience, nonce=nonce, check_exp=False) # invalid issuer with pytest.raises(InvalidClaim): - id_token.validate( - public_jwk, issuer="foo", audience=audience, nonce=nonce, check_exp=False - ) + id_token.validate(public_jwk, issuer="foo", audience=audience, nonce=nonce, check_exp=False) # invalid audience with pytest.raises(InvalidClaim): - id_token.validate( - public_jwk, issuer=issuer, audience="foo", nonce=nonce, check_exp=False - ) + id_token.validate(public_jwk, issuer=issuer, audience="foo", nonce=nonce, check_exp=False) # invalid nonce with pytest.raises(InvalidClaim): - id_token.validate( - public_jwk, issuer=issuer, audience=audience, nonce="foo", check_exp=False - ) + id_token.validate(public_jwk, issuer=issuer, audience=audience, nonce="foo", check_exp=False) # invalid claim with pytest.raises(InvalidClaim): @@ -296,12 +279,10 @@ def test_id_token_eq() -> None: assert id_token != 13.37 +@freeze_time() def test_token_serializer() -> None: serializer = BearerTokenSerializer() - assert ( - serializer.dumps(BearerToken("access_token")) - == "q1ZKTE5OLS6OL8nPTs1TskLl6iiB6fiSyoJUoJxTamJRapFSLQA" - ) - assert serializer.loads( - "q1ZKTE5OLS6OL8nPTs1TskLl6iiB6fiSyoJUoJxTamJRapFSLQA" - ) == BearerToken("access_token") + assert serializer.dumps(BearerToken("access_token")) == "q1ZKTE5OLS6OL8nPTs1TskLl6iiB6fiSyoJUoJxTamJRapFSLQA" + assert serializer.loads("q1ZKTE5OLS6OL8nPTs1TskLl6iiB6fiSyoJUoJxTamJRapFSLQA") == BearerToken("access_token") + assert serializer.dumps(BearerToken("access_token", expires_in=60)) == "q1ZKTE5OLS6OL8nPTs1TskLl6iiB6fiSyoJUoJxTamJRahFQNLWiILMotTg-E6jDzKAWAA" + assert serializer.loads("q1ZKTE5OLS6OL8nPTs1TskLl6iiB6fiSyoJUoJxTamJRahFQNLWiILMotTg-E6jDzKAWAA") == BearerToken("access_token", expires_in=60) diff --git a/tests/unit_tests/test_utils.py b/tests/unit_tests/test_utils.py index a5d99d8..df82acd 100644 --- a/tests/unit_tests/test_utils.py +++ b/tests/unit_tests/test_utils.py @@ -1,6 +1,6 @@ from __future__ import annotations -from datetime import datetime +from datetime import datetime, timezone import pytest @@ -23,7 +23,7 @@ def test_accepts_expires_in(expires_in: int | str) -> None: def foo(expires_at: datetime | None = None) -> datetime | None: return expires_at - now = datetime.now() + now = datetime.now(tz=timezone.utc) assert foo(expires_at=now) == now assert foo(now) == now assert isinstance(foo(expires_in=expires_in), datetime) diff --git a/tests/unit_tests/vendor_specific/test_auth0.py b/tests/unit_tests/vendor_specific/test_auth0.py index 665521c..f84a1a1 100644 --- a/tests/unit_tests/vendor_specific/test_auth0.py +++ b/tests/unit_tests/vendor_specific/test_auth0.py @@ -1,11 +1,11 @@ import pytest from requests_oauth2client import OAuth2ClientCredentialsAuth -from requests_oauth2client.vendor_specific import Auth0Client, Auth0ManagementApiClient +from requests_oauth2client.vendor_specific import Auth0 def test_auth0_management() -> None: - auth0api = Auth0ManagementApiClient("test.eu.auth0.com", ("client_id", "client_secret")) + auth0api = Auth0.management_api_client("test.eu.auth0.com", ("client_id", "client_secret")) assert auth0api.auth is not None assert isinstance(auth0api.auth, OAuth2ClientCredentialsAuth) assert auth0api.auth.client is not None @@ -14,7 +14,7 @@ def test_auth0_management() -> None: def test_auth0_client() -> None: - auth0client = Auth0Client("test.eu.auth0.com", ("client_id", "client_secret")) + auth0client = Auth0.client("test.eu.auth0.com", ("client_id", "client_secret")) assert auth0client.token_endpoint == "https://test.eu.auth0.com/oauth/token" assert auth0client.revocation_endpoint == "https://test.eu.auth0.com/oauth/revoke" assert auth0client.userinfo_endpoint == "https://test.eu.auth0.com/userinfo" @@ -22,13 +22,17 @@ def test_auth0_client() -> None: def test_auth0_client_short_tenant_name() -> None: - auth0client = Auth0Client("test.eu", ("client_id", "client_secret")) + auth0client = Auth0.client("test.eu", ("client_id", "client_secret")) assert auth0client.token_endpoint == "https://test.eu.auth0.com/oauth/token" assert auth0client.revocation_endpoint == "https://test.eu.auth0.com/oauth/revoke" assert auth0client.userinfo_endpoint == "https://test.eu.auth0.com/userinfo" assert auth0client.jwks_uri == "https://test.eu.auth0.com/.well-known/jwks.json" -def test_auth0_invalid_domain() -> None: +def test_tenant() -> None: + assert Auth0.tenant("https://mytenant.eu.auth0.com") == "mytenant.eu.auth0.com" + assert Auth0.tenant("mytenant.eu") == "mytenant.eu.auth0.com" with pytest.raises(ValueError): - Auth0Client("ftp://mytenant.eu") + Auth0.tenant("ftp://mytenant.eu") + with pytest.raises(ValueError): + Auth0.tenant("") diff --git a/tests/unit_tests/vendor_specific/test_ping.py b/tests/unit_tests/vendor_specific/test_ping.py index 19a607d..38f6429 100644 --- a/tests/unit_tests/vendor_specific/test_ping.py +++ b/tests/unit_tests/vendor_specific/test_ping.py @@ -1,10 +1,10 @@ import pytest -from requests_oauth2client.vendor_specific import PingClient +from requests_oauth2client.vendor_specific import Ping def test_ping_client() -> None: - ping_client = PingClient("mydomain.tld", auth=("client_id", "client_secret")) + ping_client = Ping.client("mydomain.tld", auth=("client_id", "client_secret")) assert ping_client.token_endpoint == "https://mydomain.tld/as/token.oauth2" assert ping_client.authorization_endpoint == "https://mydomain.tld/as/authorization.oauth2" assert ping_client.token_endpoint == "https://mydomain.tld/as/token.oauth2" @@ -12,10 +12,7 @@ def test_ping_client() -> None: assert ping_client.userinfo_endpoint == "https://mydomain.tld/idp/userinfo.openid" assert ping_client.introspection_endpoint == "https://mydomain.tld/as/introspect.oauth2" assert ping_client.jwks_uri == "https://mydomain.tld/pf/JWKS" - assert ( - ping_client.extra_metadata["registration_endpoint"] - == "https://mydomain.tld/as/clients.oauth2" - ) + assert ping_client.extra_metadata["registration_endpoint"] == "https://mydomain.tld/as/clients.oauth2" assert ( ping_client.extra_metadata["ping_revoked_sris_endpoint"] == "https://mydomain.tld/pf-ws/rest/sessionMgmt/revokedSris" @@ -28,18 +25,12 @@ def test_ping_client() -> None: ping_client.extra_metadata["ping_session_management_users_endpoint"] == "https://mydomain.tld/pf-ws/rest/sessionMgmt/users" ) - assert ( - ping_client.extra_metadata["ping_end_session_endpoint"] - == "https://mydomain.tld/idp/startSLO.ping" - ) - assert ( - ping_client.device_authorization_endpoint - == "https://mydomain.tld/as/device_authz.oauth2" - ) + assert ping_client.extra_metadata["ping_end_session_endpoint"] == "https://mydomain.tld/idp/startSLO.ping" + assert ping_client.device_authorization_endpoint == "https://mydomain.tld/as/device_authz.oauth2" def test_ping_invalid_domain() -> None: with pytest.raises(ValueError): - PingClient("foo") + Ping.client("foo") with pytest.raises(ValueError): - PingClient("ftp://foo.bar") + Ping.client("ftp://foo.bar")