diff --git a/AUTHORS b/AUTHORS index 2d3f80527..2d8d5465b 100644 --- a/AUTHORS +++ b/AUTHORS @@ -36,6 +36,7 @@ Bas van Oostveen Brian Helba Carl Schwan Cihad GUNDOGDU +Cristian Prigoana Daniel Golding Daniel 'Vector' Kerr Darrel O'Pry @@ -43,6 +44,7 @@ Dave Burkholder David Fischer David Hill David Smith +David Uzumaki Dawid Wolski Diego Garcia Dominik George diff --git a/CHANGELOG.md b/CHANGELOG.md index f2d44ba4f..1821a8ae1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -27,6 +27,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 * #1506 Support for Wildcard Origin and Redirect URIs - Adds a new setting [ALLOW_URL_WILDCARDS](https://django-oauth-toolkit.readthedocs.io/en/latest/settings.html#allow-uri-wildcards). This feature is useful for working with CI service such as cloudflare, netlify, and vercel that offer branch deployments for development previews and user acceptance testing. * #1586 Turkish language support added +* #1539 Add device authorization grant support ### Changed The project is now hosted in the django-oauth organization. diff --git a/docs/_images/application-register-device-code.png b/docs/_images/application-register-device-code.png new file mode 100644 index 000000000..4eac6d262 Binary files /dev/null and b/docs/_images/application-register-device-code.png differ diff --git a/docs/_images/device-approve-deny.png b/docs/_images/device-approve-deny.png new file mode 100644 index 000000000..dcff24b25 Binary files /dev/null and b/docs/_images/device-approve-deny.png differ diff --git a/docs/_images/device-enter-code-displayed.png b/docs/_images/device-enter-code-displayed.png new file mode 100644 index 000000000..201137ce3 Binary files /dev/null and b/docs/_images/device-enter-code-displayed.png differ diff --git a/docs/tutorial/tutorial.rst b/docs/tutorial/tutorial.rst index 5a0662507..140313673 100644 --- a/docs/tutorial/tutorial.rst +++ b/docs/tutorial/tutorial.rst @@ -9,4 +9,4 @@ Tutorials tutorial_03 tutorial_04 tutorial_05 - + tutorial_06 diff --git a/docs/tutorial/tutorial_06.rst b/docs/tutorial/tutorial_06.rst new file mode 100644 index 000000000..386e4ef39 --- /dev/null +++ b/docs/tutorial/tutorial_06.rst @@ -0,0 +1,126 @@ +Part 6 - Device authorization grant flow +==================================================== + +Scenario +-------- +In :doc:`Part 1 ` you created your own :term:`Authorization Server` and it's running along just fine. +You have devices that your users have, and those users need to authenticate the device against your +:term:`Authorization Server` in order to make the required API calls. + +Device Authorization +-------------------- +The OAuth 2.0 device authorization grant is designed for Internet +connected devices that either lack a browser to perform a user-agent +based authorization or are input-constrained to the extent that +requiring the user to input text in order to authenticate during the +authorization flow is impractical. It enables OAuth clients on such +devices (like smart TVs, media consoles, digital picture frames, and +printers) to obtain user authorization to access protected resources +by using a user agent on a separate device. + +Point your browser to `http://127.0.0.1:8000/o/applications/register/` to create an application. + +Fill the form as shown in the screenshot below, and before saving, take note of the ``Client id``. +Make sure the client type is set to "Public." There are cases where a confidential client makes sense, +but generally, it is assumed the device is unable to safely store the client secret. + +.. image:: ../_images/application-register-device-code.png + :alt: Device Authorization application registration + +Ensure the setting ``OAUTH_DEVICE_VERIFICATION_URI`` is set to a URI you want to return in the +`verification_uri` key in the response. This is what the device will display to the user. + +1. Navigate to the tests/app/idp directory: + +.. code-block:: sh + + cd tests/app/idp + +then start the server + +.. code-block:: sh + + python manage.py runserver + +.. _RFC: https://www.rfc-editor.org/rfc/rfc8628 +.. _RFC section 3.5: https://datatracker.ietf.org/doc/html/rfc8628#section-3.5 + +2. To initiate device authorization, send this request (in the real world, the device +makes this request). In `RFC`_ Figure 1, this is step (A). + +.. code-block:: sh + + curl --location 'http://127.0.0.1:8000/o/device-authorization/' \ + --header 'Content-Type: application/x-www-form-urlencoded' \ + --data-urlencode 'client_id={your application client id}' + +The OAuth2 provider will return the following response. In `RFC`_ Figure 1, this is step (B). + +.. code-block:: json + + { + "verification_uri": "http://127.0.0.1:8000/o/device", + "expires_in": 1800, + "user_code": "A32RVADM", + "device_code": "G30j94v0kNfipD4KmGLTWeL4eZnKHm", + "interval": 5 + } + +In the real world, the device will somehow make the value of the `user_code` available to the user (either on-screen display, +or Bluetooth, NFC, etc.). In `RFC`_ Figure 1, this is step (C). + +3. Go to `http://127.0.0.1:8000/o/device` in your browser. + +.. image:: ../_images/device-enter-code-displayed.png + +Enter the code, and it will redirect you to the device-confirm endpoint. In `RFC`_ Figure 1, this is step (D). + +Device-confirm endpoint +----------------------- +4. Device polling occurs concurrently while the user approves or denies the request. + +.. image:: ../_images/device-approve-deny.png + +Device polling +-------------- +Send the following request (in the real world, the device makes this request). In `RFC`_ Figure 1, this is step (E). + +.. code-block:: sh + + curl --location 'http://localhost:8000/o/token/' \ + --header 'Content-Type: application/x-www-form-urlencoded' \ + --data-urlencode 'device_code={the device code from the device-authorization response}' \ + --data-urlencode 'client_id={your application client id}' \ + --data-urlencode 'grant_type=urn:ietf:params:oauth:grant-type:device_code' + +In `RFC`_ Figure 1, there are multiple options for step (F), as per `RFC section 3.5`_. Until the user enters the code +in the browser and approves, the response will be 400: + +.. code-block:: json + + {"error": "authorization_pending"} + +Or if the user has denied the device, the response is 400: + +.. code-block:: json + + {"error": "access_denied"} + +Or if the token has expired, the response is 400: + +.. code-block:: json + + {"error": "expired_token"} + + +However, after the user approves, the response will be 200: + +.. code-block:: json + + { + "access_token": "SkJMgyL432P04nHDPyB63DEAM0nVxk", + "expires_in": 36000, + "token_type": "Bearer", + "scope": "openid", + "refresh_token": "Go6VumurDfFAeCeKrpCKPDtElV77id" + } diff --git a/oauth2_provider/migrations/0013_alter_application_authorization_grant_type_device.py b/oauth2_provider/migrations/0013_alter_application_authorization_grant_type_device.py new file mode 100644 index 000000000..99769c398 --- /dev/null +++ b/oauth2_provider/migrations/0013_alter_application_authorization_grant_type_device.py @@ -0,0 +1,41 @@ +# Generated by Django 5.1.5 on 2025-01-24 14:00 + +import django.db.models.deletion +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('oauth2_provider', '0012_add_token_checksum'), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.AlterField( + model_name='application', + name='authorization_grant_type', + field=models.CharField(choices=[('authorization-code', 'Authorization code'), ('urn:ietf:params:oauth:grant-type:device_code', 'Device Code'), ('implicit', 'Implicit'), ('password', 'Resource owner password-based'), ('client-credentials', 'Client credentials'), ('openid-hybrid', 'OpenID connect hybrid')], max_length=44), + ), + migrations.CreateModel( + name='DeviceGrant', + fields=[ + ('id', models.BigAutoField(primary_key=True, serialize=False)), + ('device_code', models.CharField(max_length=100, unique=True)), + ('user_code', models.CharField(max_length=100)), + ('scope', models.CharField(max_length=64, null=True)), + ('interval', models.IntegerField(default=5)), + ('expires', models.DateTimeField()), + ('status', models.CharField(blank=True, choices=[('authorized', 'Authorized'), ('authorization-pending', 'Authorization pending'), ('expired', 'Expired'), ('denied', 'Denied')], default='authorization-pending', max_length=64)), + ('client_id', models.CharField(db_index=True, max_length=100)), + ('last_checked', models.DateTimeField(auto_now=True)), + ('user', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='%(app_label)s_%(class)s', to=settings.AUTH_USER_MODEL)), + ], + options={ + 'abstract': False, + 'swappable': 'OAUTH2_PROVIDER_DEVICE_GRANT_MODEL', + 'constraints': [models.UniqueConstraint(fields=('device_code',), name='oauth2_provider_devicegrant_unique_device_code')], + }, + ), + ] diff --git a/oauth2_provider/models.py b/oauth2_provider/models.py index a76db37c0..523ade289 100644 --- a/oauth2_provider/models.py +++ b/oauth2_provider/models.py @@ -3,7 +3,10 @@ import time import uuid from contextlib import suppress -from datetime import timedelta +from dataclasses import dataclass +from datetime import datetime, timedelta +from datetime import timezone as dt_timezone +from typing import Callable, Optional, Union from urllib.parse import parse_qsl, urlparse from django.apps import apps @@ -86,12 +89,14 @@ class AbstractApplication(models.Model): ) GRANT_AUTHORIZATION_CODE = "authorization-code" + GRANT_DEVICE_CODE = "urn:ietf:params:oauth:grant-type:device_code" GRANT_IMPLICIT = "implicit" GRANT_PASSWORD = "password" GRANT_CLIENT_CREDENTIALS = "client-credentials" GRANT_OPENID_HYBRID = "openid-hybrid" GRANT_TYPES = ( (GRANT_AUTHORIZATION_CODE, _("Authorization code")), + (GRANT_DEVICE_CODE, _("Device Code")), (GRANT_IMPLICIT, _("Implicit")), (GRANT_PASSWORD, _("Resource owner password-based")), (GRANT_CLIENT_CREDENTIALS, _("Client credentials")), @@ -127,7 +132,7 @@ class AbstractApplication(models.Model): default="", ) client_type = models.CharField(max_length=32, choices=CLIENT_TYPES) - authorization_grant_type = models.CharField(max_length=32, choices=GRANT_TYPES) + authorization_grant_type = models.CharField(max_length=44, choices=GRANT_TYPES) client_secret = ClientSecretField( max_length=255, blank=True, @@ -650,11 +655,109 @@ class Meta(AbstractIDToken.Meta): swappable = "OAUTH2_PROVIDER_ID_TOKEN_MODEL" +class AbstractDeviceGrant(models.Model): + class Meta: + abstract = True + constraints = [ + models.UniqueConstraint( + fields=["device_code"], + name="%(app_label)s_%(class)s_unique_device_code", + ), + ] + + AUTHORIZED = "authorized" + AUTHORIZATION_PENDING = "authorization-pending" + EXPIRED = "expired" + DENIED = "denied" + + DEVICE_FLOW_STATUS = ( + (AUTHORIZED, _("Authorized")), + (AUTHORIZATION_PENDING, _("Authorization pending")), + (EXPIRED, _("Expired")), + (DENIED, _("Denied")), + ) + + id = models.BigAutoField(primary_key=True) + user = models.ForeignKey( + settings.AUTH_USER_MODEL, + related_name="%(app_label)s_%(class)s", + null=True, + blank=True, + on_delete=models.CASCADE, + ) + device_code = models.CharField(max_length=100, unique=True) + user_code = models.CharField(max_length=100) + scope = models.CharField(max_length=64, null=True) + interval = models.IntegerField(default=5) + expires = models.DateTimeField() + status = models.CharField( + max_length=64, blank=True, choices=DEVICE_FLOW_STATUS, default=AUTHORIZATION_PENDING + ) + client_id = models.CharField(max_length=100, db_index=True) + last_checked = models.DateTimeField(auto_now=True) + + def is_expired(self): + """ + Check device flow session expiration and set the status to "expired" if current time + is past the "expires" deadline. + """ + if self.status == self.EXPIRED: + return True + + now = datetime.now(tz=dt_timezone.utc) + if now >= self.expires: + self.status = self.EXPIRED + self.save(update_fields=["status"]) + return True + + return False + + +class DeviceGrant(AbstractDeviceGrant): + class Meta(AbstractDeviceGrant.Meta): + swappable = "OAUTH2_PROVIDER_DEVICE_GRANT_MODEL" + + +@dataclass +class DeviceRequest: + # https://datatracker.ietf.org/doc/html/rfc8628#section-3.1 + # scope is optional + client_id: str + scope: Optional[str] = None + + +@dataclass +class DeviceCodeResponse: + verification_uri: str + expires_in: int + user_code: int + device_code: str + interval: int + verification_uri_complete: Optional[Union[str, Callable]] = None + + +def create_device_grant(device_request: DeviceRequest, device_response: DeviceCodeResponse) -> DeviceGrant: + now = datetime.now(tz=dt_timezone.utc) + + return DeviceGrant.objects.create( + client_id=device_request.client_id, + device_code=device_response.device_code, + user_code=device_response.user_code, + scope=device_request.scope, + expires=now + timedelta(seconds=device_response.expires_in), + ) + + def get_application_model(): """Return the Application model that is active in this project.""" return apps.get_model(oauth2_settings.APPLICATION_MODEL) +def get_device_grant_model(): + """Return the DeviceGrant model that is active in this project.""" + return apps.get_model(oauth2_settings.DEVICE_GRANT_MODEL) + + def get_grant_model(): """Return the Grant model that is active in this project.""" return apps.get_model(oauth2_settings.GRANT_MODEL) diff --git a/oauth2_provider/oauth2_backends.py b/oauth2_provider/oauth2_backends.py index 3ddb9c90b..accd9d3f8 100644 --- a/oauth2_provider/oauth2_backends.py +++ b/oauth2_provider/oauth2_backends.py @@ -1,6 +1,7 @@ import json from urllib.parse import urlparse, urlunparse +from django.http import HttpRequest from oauthlib import oauth2 from oauthlib.common import Request as OauthlibRequest from oauthlib.common import quote, urlencode, urlencoded @@ -75,6 +76,8 @@ def extract_headers(self, request): del headers["wsgi.errors"] if "HTTP_AUTHORIZATION" in headers: headers["Authorization"] = headers["HTTP_AUTHORIZATION"] + if "CONTENT_TYPE" in headers: + headers["Content-Type"] = headers["CONTENT_TYPE"] # Add Access-Control-Allow-Origin header to the token endpoint response for authentication code grant, # if the origin is allowed by RequestValidator.is_origin_allowed. # https://github.com/oauthlib/oauthlib/pull/791 @@ -148,6 +151,16 @@ def create_authorization_response(self, request, scopes, credentials, allow): except oauth2.OAuth2Error as error: raise OAuthToolkitError(error=error, redirect_uri=credentials["redirect_uri"]) + def create_device_authorization_response(self, request: HttpRequest): + uri, http_method, body, headers = self._extract_params(request) + try: + headers, body, status = self.server.create_device_authorization_response( + uri, http_method, body, headers + ) + return headers, body, status + except OAuth2Error as exc: + return exc.headers, exc.json, exc.status_code + def create_token_response(self, request): """ A wrapper method that calls create_token_response on `server_class` instance. diff --git a/oauth2_provider/oauth2_validators.py b/oauth2_provider/oauth2_validators.py index db459a446..ec974b0c6 100644 --- a/oauth2_provider/oauth2_validators.py +++ b/oauth2_provider/oauth2_validators.py @@ -52,10 +52,12 @@ "client_credentials": (AbstractApplication.GRANT_CLIENT_CREDENTIALS,), "refresh_token": ( AbstractApplication.GRANT_AUTHORIZATION_CODE, + AbstractApplication.GRANT_DEVICE_CODE, AbstractApplication.GRANT_PASSWORD, AbstractApplication.GRANT_CLIENT_CREDENTIALS, AbstractApplication.GRANT_OPENID_HYBRID, ), + "urn:ietf:params:oauth:grant-type:device_code": (AbstractApplication.GRANT_DEVICE_CODE,), } Application = get_application_model() @@ -166,6 +168,11 @@ def _authenticate_basic_auth(self, request): elif request.client.client_id != client_id: log.debug("Failed basic auth: wrong client id %s" % client_id) return False + elif ( + request.client.client_type == "public" + and request.grant_type == "urn:ietf:params:oauth:grant-type:device_code" + ): + return True elif not self._check_secret(client_secret, request.client.client_secret): log.debug("Failed basic auth: wrong client secret %s" % client_secret) return False @@ -191,6 +198,11 @@ def _authenticate_request_body(self, request): if self._load_application(client_id, request) is None: log.debug("Failed body auth: Application %s does not exists" % client_id) return False + elif ( + request.client.client_type == "public" + and request.grant_type == "urn:ietf:params:oauth:grant-type:device_code" + ): + return True elif not self._check_secret(client_secret, request.client.client_secret): log.debug("Failed body auth: wrong client secret %s" % client_secret) return False diff --git a/oauth2_provider/settings.py b/oauth2_provider/settings.py index 9771aa4e7..216f36ba8 100644 --- a/oauth2_provider/settings.py +++ b/oauth2_provider/settings.py @@ -24,10 +24,13 @@ from django.utils.module_loading import import_string from oauthlib.common import Request +from oauth2_provider.utils import set_oauthlib_user_to_device_request_user, user_code_generator + USER_SETTINGS = getattr(settings, "OAUTH2_PROVIDER", None) APPLICATION_MODEL = getattr(settings, "OAUTH2_PROVIDER_APPLICATION_MODEL", "oauth2_provider.Application") +DEVICE_GRANT_MODEL = getattr(settings, "OAUTH2_PROVIDER_DEVICE_GRANT_MODEL", "oauth2_provider.DeviceGrant") ACCESS_TOKEN_MODEL = getattr(settings, "OAUTH2_PROVIDER_ACCESS_TOKEN_MODEL", "oauth2_provider.AccessToken") ID_TOKEN_MODEL = getattr(settings, "OAUTH2_PROVIDER_ID_TOKEN_MODEL", "oauth2_provider.IDToken") GRANT_MODEL = getattr(settings, "OAUTH2_PROVIDER_GRANT_MODEL", "oauth2_provider.Grant") @@ -39,6 +42,10 @@ "CLIENT_SECRET_GENERATOR_LENGTH": 128, "CLIENT_SECRET_HASHER": "default", "ACCESS_TOKEN_GENERATOR": None, + "OAUTH_DEVICE_VERIFICATION_URI": None, + "OAUTH_DEVICE_VERIFICATION_URI_COMPLETE": None, + "OAUTH_DEVICE_USER_CODE_GENERATOR": user_code_generator, + "OAUTH_PRE_TOKEN_VALIDATION": [set_oauthlib_user_to_device_request_user], "REFRESH_TOKEN_GENERATOR": None, "EXTRA_SERVER_KWARGS": {}, "OAUTH2_SERVER_CLASS": "oauthlib.oauth2.Server", @@ -61,6 +68,8 @@ "APPLICATION_MODEL": APPLICATION_MODEL, "ACCESS_TOKEN_MODEL": ACCESS_TOKEN_MODEL, "ID_TOKEN_MODEL": ID_TOKEN_MODEL, + "DEVICE_GRANT_MODEL": DEVICE_GRANT_MODEL, + "DEVICE_FLOW_INTERVAL": 5, "GRANT_MODEL": GRANT_MODEL, "REFRESH_TOKEN_MODEL": REFRESH_TOKEN_MODEL, "APPLICATION_ADMIN_CLASS": "oauth2_provider.admin.ApplicationAdmin", @@ -268,6 +277,11 @@ def server_kwargs(self): ("refresh_token_expires_in", "REFRESH_TOKEN_EXPIRE_SECONDS"), ("token_generator", "ACCESS_TOKEN_GENERATOR"), ("refresh_token_generator", "REFRESH_TOKEN_GENERATOR"), + ("verification_uri", "OAUTH_DEVICE_VERIFICATION_URI"), + ("verification_uri_complete", "OAUTH_DEVICE_VERIFICATION_URI_COMPLETE"), + ("interval", "DEVICE_FLOW_INTERVAL"), + ("user_code_generator", "OAUTH_DEVICE_USER_CODE_GENERATOR"), + ("pre_token", "OAUTH_PRE_TOKEN_VALIDATION"), ] } kwargs.update(self.EXTRA_SERVER_KWARGS) diff --git a/oauth2_provider/templates/oauth2_provider/device/accept_deny.html b/oauth2_provider/templates/oauth2_provider/device/accept_deny.html new file mode 100644 index 000000000..4fd31a6fb --- /dev/null +++ b/oauth2_provider/templates/oauth2_provider/device/accept_deny.html @@ -0,0 +1,16 @@ +{% extends "oauth2_provider/base.html" %} +{% block content %} + + + Accept or Deny + + +

Please choose an action:

+
+ {% csrf_token %} + + +
+ + +{% endblock content %} diff --git a/oauth2_provider/templates/oauth2_provider/device/device_grant_status.html b/oauth2_provider/templates/oauth2_provider/device/device_grant_status.html new file mode 100644 index 000000000..f2f0a6292 --- /dev/null +++ b/oauth2_provider/templates/oauth2_provider/device/device_grant_status.html @@ -0,0 +1,11 @@ +{% extends "oauth2_provider/base.html" %} +{% block content %} + + + Device + + +

Device {{ object.get_status_display }}

+ + +{% endblock content %} diff --git a/oauth2_provider/templates/oauth2_provider/device/user_code.html b/oauth2_provider/templates/oauth2_provider/device/user_code.html new file mode 100644 index 000000000..774b95897 --- /dev/null +++ b/oauth2_provider/templates/oauth2_provider/device/user_code.html @@ -0,0 +1,16 @@ +{% extends "oauth2_provider/base.html" %} +{% block content %} + + + Device code + + +

Enter code displayed on device

+
+ {% csrf_token %} + {{ form.as_p }} + +
+ + +{% endblock content %} diff --git a/oauth2_provider/urls.py b/oauth2_provider/urls.py index 155822f45..ea974e045 100644 --- a/oauth2_provider/urls.py +++ b/oauth2_provider/urls.py @@ -11,6 +11,18 @@ path("token/", views.TokenView.as_view(), name="token"), path("revoke_token/", views.RevokeTokenView.as_view(), name="revoke-token"), path("introspect/", views.IntrospectTokenView.as_view(), name="introspect"), + path("device-authorization/", views.DeviceAuthorizationView.as_view(), name="device-authorization"), + path("device/", views.DeviceUserCodeView.as_view(), name="device"), + path( + "device-confirm//", + views.DeviceConfirmView.as_view(), + name="device-confirm", + ), + path( + "device-grant-status//", + views.DeviceGrantStatusView.as_view(), + name="device-grant-status", + ), ] diff --git a/oauth2_provider/utils.py b/oauth2_provider/utils.py index 3f48723c5..a009d8a0e 100644 --- a/oauth2_provider/utils.py +++ b/oauth2_provider/utils.py @@ -1,7 +1,9 @@ import functools +import random from django.conf import settings from jwcrypto import jwk +from oauthlib.common import Request @functools.lru_cache() @@ -32,3 +34,69 @@ def get_timezone(time_zone): return pytz.timezone(time_zone) return zoneinfo.ZoneInfo(time_zone) + + +def user_code_generator(user_code_length: int = 8) -> str: + """ + Recommended user code that retains enough entropy but doesn't + ruin the user experience of typing the code in. + + the below is based off: + https://datatracker.ietf.org/doc/html/rfc8628#section-5.1 + but with added explanation as to where 34.5 bits of entropy is coming from + + entropy (in bits) = length of user code * log2(length of set of chars) + e = 8 * log2(20) + e = 34.5 + + log2(20) is used here to say "you can make 20 yes/no decisions per user code single input character". + + _ _ _ _ - _ _ _ _ = 20^8 ~= 2^35.5 + * + + * you have 20 choices of chars to choose from (20 yes no decisions) + and so on for the other 7 spaces + + in english this means an attacker would need to try + 2^34.5 unique combinations to exhaust all possibilities. + however with a user code only being valid for 30 seconds + and rate limiting, a brute force attack is extremely unlikely + to work + + for our function we'll be using a base 32 character set + """ + if user_code_length < 1: + raise ValueError("user_code_length needs to be greater than 0") + + # base32 character space + character_space = "0123456789ABCDEFGHIJKLMNOPQRSTUV" + + # being explicit with length + user_code = [""] * user_code_length + + for i in range(user_code_length): + user_code[i] = random.choice(character_space) + + return "".join(user_code) + + +def set_oauthlib_user_to_device_request_user(request: Request) -> None: + """ + The user isn't known when the device flow is initiated by a device. + All we know is the client_id. + + However, when the user logins in order to submit the user code + from the device we now know which user is trying to authenticate + their device. We update the device user field at this point + and save it in the db. + + This function is added to the pre_token stage during the device code grant's + create_token_response where we have the oauthlib Request object which is what's used + to populate the user field in the device model + """ + # Since this function is used in the settings module, it will lead to circular imports + # since django isn't fully initialised yet when settings run + from oauth2_provider.models import DeviceGrant, get_device_grant_model + + device: DeviceGrant = get_device_grant_model().objects.get(device_code=request._params["device_code"]) + request.user = device.user diff --git a/oauth2_provider/views/__init__.py b/oauth2_provider/views/__init__.py index 9e32e17d8..24022f55e 100644 --- a/oauth2_provider/views/__init__.py +++ b/oauth2_provider/views/__init__.py @@ -17,3 +17,4 @@ from .introspect import IntrospectTokenView from .oidc import ConnectDiscoveryInfoView, JwksInfoView, RPInitiatedLogoutView, UserInfoView from .token import AuthorizedTokenDeleteView, AuthorizedTokensListView +from .device import DeviceAuthorizationView, DeviceUserCodeView, DeviceConfirmView, DeviceGrantStatusView diff --git a/oauth2_provider/views/base.py b/oauth2_provider/views/base.py index c5c904b14..43c8e3213 100644 --- a/oauth2_provider/views/base.py +++ b/oauth2_provider/views/base.py @@ -3,6 +3,7 @@ import logging from urllib.parse import parse_qsl, urlencode, urlparse +from django import http from django.contrib.auth.mixins import LoginRequiredMixin from django.contrib.auth.views import redirect_to_login from django.http import HttpResponse @@ -12,6 +13,9 @@ from django.views.decorators.csrf import csrf_exempt from django.views.decorators.debug import sensitive_post_parameters from django.views.generic import FormView, View +from oauthlib.oauth2.rfc8628 import errors as rfc8628_errors + +from oauth2_provider.models import DeviceGrant from ..compat import login_not_required from ..exceptions import OAuthToolkitError @@ -290,10 +294,13 @@ class TokenView(OAuthLibMixin, View): * Authorization code * Password * Client credentials + * Device code flow (specifically for the device polling stage) """ @method_decorator(sensitive_post_parameters("password", "client_secret")) - def post(self, request, *args, **kwargs): + def authorization_flow_token_response( + self, request: http.HttpRequest, *args, **kwargs + ) -> http.HttpResponse: url, headers, body, status = self.create_token_response(request) if status == 200: access_token = json.loads(body).get("access_token") @@ -307,6 +314,68 @@ def post(self, request, *args, **kwargs): response[k] = v return response + def device_flow_token_response( + self, request: http.HttpRequest, device_code: str, *args, **kwargs + ) -> http.HttpResponse: + try: + device = DeviceGrant.objects.get(device_code=device_code) + except DeviceGrant.DoesNotExist: + # The RFC does not mention what to return when the device is not found, + # but to keep it consistent with the other errors, we return the error + # in json format with an "error" key and the value formatted in the same + # way. + return http.HttpResponseNotFound( + content='{"error": "device_not_found"}', + content_type="application/json", + ) + + # Here we are returning the errors according to + # https://datatracker.ietf.org/doc/html/rfc8628#section-3.5 + # TODO: "slow_down" error (essentially rate-limiting). + if device.status == device.AUTHORIZATION_PENDING: + error = rfc8628_errors.AuthorizationPendingError() + elif device.status == device.DENIED: + error = rfc8628_errors.AccessDenied() + elif device.status == device.EXPIRED: + error = rfc8628_errors.ExpiredTokenError() + elif device.status != device.AUTHORIZED: + # It's technically impossible to get here because we've exhausted + # all the possible values for status. However, it does act as a + # reminder for developers when they add, in the future, new values + # (such as slow_down) that they must handle here. + return http.HttpResponseServerError( + content='{"error": "internal_error"}', + content_type="application/json", + ) + else: + # AUTHORIZED is the only accepted state, anything else is + # rejected. + error = None + + if error: + return http.HttpResponse( + content=error.json, + status=error.status_code, + content_type="application/json", + ) + + url, headers, body, status = self.create_token_response(request) + response = http.JsonResponse(data=json.loads(body), status=status) + + if status != 200: + return response + + for k, v in headers.items(): + response[k] = v + + return response + + def post(self, request: http.HttpRequest, *args, **kwargs) -> http.HttpResponse: + params = request.POST + if params.get("grant_type") == "urn:ietf:params:oauth:grant-type:device_code": + return self.device_flow_token_response(request, params["device_code"]) + return self.authorization_flow_token_response(request) + @method_decorator(csrf_exempt, name="dispatch") @method_decorator(login_not_required, name="dispatch") diff --git a/oauth2_provider/views/device.py b/oauth2_provider/views/device.py new file mode 100644 index 000000000..f3dccf2ba --- /dev/null +++ b/oauth2_provider/views/device.py @@ -0,0 +1,196 @@ +import json + +from django import forms, http +from django.contrib.auth.mixins import LoginRequiredMixin +from django.core.exceptions import ValidationError +from django.shortcuts import get_object_or_404 +from django.urls import reverse +from django.utils.decorators import method_decorator +from django.views.decorators.csrf import csrf_exempt +from django.views.generic import DetailView, FormView, View +from oauthlib.oauth2 import DeviceApplicationServer + +from oauth2_provider.compat import login_not_required +from oauth2_provider.models import ( + DeviceCodeResponse, + DeviceGrant, + DeviceRequest, + create_device_grant, + get_device_grant_model, +) +from oauth2_provider.views.mixins import OAuthLibMixin + + +@method_decorator(csrf_exempt, name="dispatch") +@method_decorator(login_not_required, name="dispatch") +class DeviceAuthorizationView(OAuthLibMixin, View): + server_class = DeviceApplicationServer + + def post(self, request, *args, **kwargs): + headers, response, status = self.create_device_authorization_response(request) + + if status != 200: + return http.JsonResponse(data=json.loads(response), status=status, headers=headers) + + device_request = DeviceRequest(client_id=request.POST["client_id"], scope=request.POST.get("scope")) + device_response = DeviceCodeResponse(**response) + create_device_grant(device_request, device_response) + + return http.JsonResponse(data=response, status=status, headers=headers) + + +class DeviceGrantForm(forms.Form): + user_code = forms.CharField(required=True) + + def clean_user_code(self): + """ + Performs validation on the user_code provided by the user and adds to the cleaned_data dict + the "device_grant" object associated with the user_code, which is useful to process the + response in the DeviceUserCodeView. + + It can raise one of the following ValidationErrors, with the associated codes: + + * incorrect_user_code: if a device grant associated with the user_code does not exist + * expired_user_code: if the device grant associated with the user_code has expired + * user_code_already_used: if the device grant associated with the user_code has been already + approved or denied. The only accepted state of the device grant is AUTHORIZATION_PENDING. + """ + cleaned_data = super().clean() + user_code: str = cleaned_data["user_code"] + try: + device_grant: DeviceGrant = get_device_grant_model().objects.get(user_code=user_code) + except DeviceGrant.DoesNotExist: + raise ValidationError("Incorrect user code", code="incorrect_user_code") + + if device_grant.is_expired(): + raise ValidationError("Expired user code", code="expired_user_code") + + # User of device has already made their decision for this device. + if device_grant.status != device_grant.AUTHORIZATION_PENDING: + raise ValidationError("User code has already been used", code="user_code_already_used") + + # Make the device_grant available to the View, saving one additional db call. + cleaned_data["device_grant"] = device_grant + + return user_code + + +class DeviceUserCodeView(LoginRequiredMixin, FormView): + """ + The view where the user is instructed (by the device) to come to in order to + enter the user code. More details in this section of the RFC: + https://datatracker.ietf.org/doc/html/rfc8628#section-3.3 + + Note: it's common to see in other implementations of this RFC that only ask the + user to sign in after they input the user code but since the user has to be signed + in regardless, to approve the device login we're making the decision here, for + simplicity, to require being logged in up front. + """ + + template_name = "oauth2_provider/device/user_code.html" + form_class = DeviceGrantForm + + def get_success_url(self): + return reverse( + "oauth2_provider:device-confirm", + kwargs={ + "client_id": self.device_grant.client_id, + "user_code": self.device_grant.user_code, + }, + ) + + def form_valid(self, form): + """ + Sets the device_grant on the instance so that it can be accessed + in get_success_url. It comes in handy when users want to overwrite + get_success_url, redirecting to the URL with the URL params pointing + to the current device. + """ + device_grant: DeviceGrant = form.cleaned_data["device_grant"] + + device_grant.user = self.request.user + device_grant.save(update_fields=["user"]) + + self.device_grant = device_grant + + return super().form_valid(form) + + +class DeviceConfirmForm(forms.Form): + """ + Simple form for the user to approve or deny the device. + """ + + action = forms.CharField(required=True) + + +class DeviceConfirmView(LoginRequiredMixin, FormView): + """ + The view where the user approves or denies a device. + """ + + template_name = "oauth2_provider/device/accept_deny.html" + form_class = DeviceConfirmForm + + def get_object(self): + """ + Returns the DeviceGrant object in the AUTHORIZATION_PENDING state identified + by the slugs client_id and user_code. Raises Http404 if not found. + """ + client_id, user_code = self.kwargs.get("client_id"), self.kwargs.get("user_code") + return get_object_or_404( + DeviceGrant, + client_id=client_id, + user_code=user_code, + status=DeviceGrant.AUTHORIZATION_PENDING, + ) + + def get_success_url(self): + return reverse( + "oauth2_provider:device-grant-status", + kwargs={ + "client_id": self.kwargs["client_id"], + "user_code": self.kwargs["user_code"], + }, + ) + + def get(self, request, *args, **kwargs): + """ + Enable GET requests for improved user experience. But validate that the URL params + are correct (i.e. there exists a device grant in the db that corresponds to the URL + params) by calling .get_object() + """ + _ = self.get_object() # raises 404 if URL parameters are incorrect + return super().get(request, args, kwargs) + + def form_valid(self, form): + """ + Uses get_object() to retrieves the DeviceGrant object and updates its state + to authorized or denied, based on the user input. + """ + device = self.get_object() + action = form.cleaned_data["action"] + + if action == "accept": + device.status = device.AUTHORIZED + device.save(update_fields=["status"]) + return super().form_valid(form) + elif action == "deny": + device.status = device.DENIED + device.save(update_fields=["status"]) + return super().form_valid(form) + else: + return http.HttpResponseBadRequest() + + +class DeviceGrantStatusView(LoginRequiredMixin, DetailView): + """ + The view to display the status of a DeviceGrant. + """ + + model = DeviceGrant + template_name = "oauth2_provider/device/device_grant_status.html" + + def get_object(self): + client_id, user_code = self.kwargs.get("client_id"), self.kwargs.get("user_code") + return get_object_or_404(DeviceGrant, client_id=client_id, user_code=user_code) diff --git a/oauth2_provider/views/mixins.py b/oauth2_provider/views/mixins.py index 203d0103b..be2a77e8d 100644 --- a/oauth2_provider/views/mixins.py +++ b/oauth2_provider/views/mixins.py @@ -2,7 +2,7 @@ from django.conf import settings from django.core.exceptions import ImproperlyConfigured, SuspiciousOperation -from django.http import HttpResponseForbidden, HttpResponseNotFound +from django.http import HttpRequest, HttpResponseForbidden, HttpResponseNotFound from ..exceptions import FatalClientError from ..scopes import get_scopes_backend @@ -114,6 +114,15 @@ def create_authorization_response(self, request, scopes, credentials, allow): core = self.get_oauthlib_core() return core.create_authorization_response(request, scopes, credentials, allow) + def create_device_authorization_response(self, request: HttpRequest): + """ + A wrapper method that calls create_device_authorization_response on `server_class` + instance. + :param request: The current django.http.HttpRequest object + """ + core = self.get_oauthlib_core() + return core.create_device_authorization_response(request) + def create_token_response(self, request): """ A wrapper method that calls create_token_response on `server_class` instance. diff --git a/pyproject.toml b/pyproject.toml index 27bdfb585..a9ade3b7a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -39,7 +39,7 @@ classifiers = [ dependencies = [ "django >= 4.2", "requests >= 2.13.0", - "oauthlib >= 3.2.2", + "oauthlib >= 3.3.0", "jwcrypto >= 1.5.0", ] diff --git a/tests/app/README.md b/tests/app/README.md index a2632b262..1d13b2414 100644 --- a/tests/app/README.md +++ b/tests/app/README.md @@ -4,6 +4,8 @@ These apps are for local end to end testing of DOT features. They were implement local test environments. You should be able to start both and instance of the IDP and RP using the directions below, then test the functionality of the IDP using the RP. +The IDP seed data includes a Device Authorization OAuth application as well. + ## /tests/app/idp This is an example IDP implementation for end to end testing. There are pre-configured fixtures which will work with the sample RP. @@ -29,9 +31,39 @@ password: password You can update data in the IDP and then dump the data to a new seed file as follows. - ``` +``` python -Xutf8 ./manage.py dumpdata -e sessions -e admin.logentry -e auth.permission -e contenttypes.contenttype -e oauth2_provider.accesstoken -e oauth2_provider.refreshtoken -e oauth2_provider.idtoken --natural-foreign --natural-primary --indent 2 > fixtures/seed.json - ``` +``` + +### Device Authorization example + +For testing out the device authorization flow, we don't really need a RP, as the device itself +is the "relying party". The seed data includes a Device Authorization Application, meaning +you could directly start the device authorization flow using `curl`. In the real world, the device +would be sending these request that we send here with `curl`. + +_Note:_ you can find these `curl` commands in the Tutorial section of the documentation as well. + +```sh +# Initiate device authorization flow on the device; here we use the client_id +# of the Device Authorization App from the seed data. +curl --location 'http://127.0.0.1:8000/o/device-authorization/' \ + --header 'Content-Type: application/x-www-form-urlencoded' \ + --data-urlencode 'client_id=Qg8AaxKLs1c2W3PR70Sv5QxuSEREicKUlf83iGX3' +``` + +Follow the `verification_uri` from the response (should be similar to http://127.0.0.1:8000/o/device"), +enter the user code, approve, and then send another `curl` command to get the token. + +```sh +curl --location 'http://localhost:8000/o/token/' \ + --header 'Content-Type: application/x-www-form-urlencoded' \ + --data-urlencode 'device_code={the device code from the device-authorization response}' \ + --data-urlencode 'client_id=Qg8AaxKLs1c2W3PR70Sv5QxuSEREicKUlf83iGX3' \ + --data-urlencode 'grant_type=urn:ietf:params:oauth:grant-type:device_code' +``` + +The response should include the access token. ## /test/app/rp diff --git a/tests/app/idp/fixtures/seed.json b/tests/app/idp/fixtures/seed.json index b77d1f4e2..382102373 100644 --- a/tests/app/idp/fixtures/seed.json +++ b/tests/app/idp/fixtures/seed.json @@ -34,5 +34,26 @@ "algorithm": "RS256", "allowed_origins": "http://localhost:5173\r\nhttp://127.0.0.1:5173" } +}, +{ + "model": "oauth2_provider.application", + "fields": { + "client_id": "Qg8AaxKLs1c2W3PR70Sv5QxuSEREicKUlf83iGX3", + "user": [ + "superuser" + ], + "redirect_uris": "", + "post_logout_redirect_uris": "", + "client_type": "public", + "authorization_grant_type": "urn:ietf:params:oauth:grant-type:device_code", + "client_secret": "pbkdf2_sha256$870000$x1A7AKB9YMmNX7v2otXt1C$Yxucj9o/QlF16AxqN5LXo+Se0Sy3FO5x4Q35Lw1FGqM=", + "hash_client_secret": true, + "name": "Device Authorization App", + "skip_authorization": false, + "created": "2025-11-07T16:56:23.156Z", + "updated": "2025-11-07T16:56:23.156Z", + "algorithm": "", + "allowed_origins": "" + } } ] diff --git a/tests/app/idp/idp/apps.py b/tests/app/idp/idp/apps.py index f40a9f644..63a7f442f 100644 --- a/tests/app/idp/idp/apps.py +++ b/tests/app/idp/idp/apps.py @@ -3,11 +3,20 @@ def cors_allow_origin(sender, request, **kwargs): + origin = request.headers.get('Origin') + return ( request.path == "/o/userinfo/" or request.path == "/o/userinfo" or request.path == "/o/.well-known/openid-configuration" or request.path == "/o/.well-known/openid-configuration/" + # this is for testing the device authorization flow in the example rp. + # You would not normally have a browser-based client do this and shoudn't + # open the following endpoints to CORS requests in a production environment. + or (origin == 'http://localhost:5173' and request.path == "/o/device-authorization") + or (origin == 'http://localhost:5173' and request.path == "/o/device-authorization/") + or (origin == 'http://localhost:5173' and request.path == "/o/token") + or (origin == 'http://localhost:5173' and request.path == "/o/token/") ) diff --git a/tests/app/idp/idp/settings.py b/tests/app/idp/idp/settings.py index eee20982e..679407604 100644 --- a/tests/app/idp/idp/settings.py +++ b/tests/app/idp/idp/settings.py @@ -15,6 +15,8 @@ import environ +from oauth2_provider.utils import set_oauthlib_user_to_device_request_user, user_code_generator + # Build paths inside the project like this: BASE_DIR / 'subdir'. BASE_DIR = Path(__file__).resolve().parent.parent @@ -199,6 +201,10 @@ OAUTH2_PROVIDER = { "OAUTH2_VALIDATOR_CLASS": "idp.oauth.CustomOAuth2Validator", + "OAUTH_DEVICE_VERIFICATION_URI": "http://127.0.0.1:8000/o/device", + "OAUTH_PRE_TOKEN_VALIDATION": [set_oauthlib_user_to_device_request_user], + "OAUTH_DEVICE_USER_CODE_GENERATOR": user_code_generator, + "OAUTH_DEVICE_VERIFICATION_URI_COMPLETE": lambda x: f"http://127.0.0.1:8000/o/device?user_code={x}", "OIDC_ENABLED": env("OAUTH2_PROVIDER_OIDC_ENABLED"), "OIDC_RP_INITIATED_LOGOUT_ENABLED": env("OAUTH2_PROVIDER_OIDC_RP_INITIATED_LOGOUT_ENABLED"), # this key is just for out test app, you should never store a key like this in a production environment. diff --git a/tests/app/idp/requirements.txt b/tests/app/idp/requirements.txt index f607463d7..f8d653aba 100644 --- a/tests/app/idp/requirements.txt +++ b/tests/app/idp/requirements.txt @@ -1,5 +1,5 @@ Django>=4.2,<=5.2 -django-cors-headers==3.14.0 -django-environ==0.11.2 +django-cors-headers==4.6.0 +django-environ==0.12.0 -e ../../../ diff --git a/tests/app/idp/templates/device/accept_deny.html b/tests/app/idp/templates/device/accept_deny.html new file mode 100644 index 000000000..4fd31a6fb --- /dev/null +++ b/tests/app/idp/templates/device/accept_deny.html @@ -0,0 +1,16 @@ +{% extends "oauth2_provider/base.html" %} +{% block content %} + + + Accept or Deny + + +

Please choose an action:

+
+ {% csrf_token %} + + +
+ + +{% endblock content %} diff --git a/tests/app/idp/templates/device/user_code.html b/tests/app/idp/templates/device/user_code.html new file mode 100644 index 000000000..774b95897 --- /dev/null +++ b/tests/app/idp/templates/device/user_code.html @@ -0,0 +1,16 @@ +{% extends "oauth2_provider/base.html" %} +{% block content %} + + + Device code + + +

Enter code displayed on device

+
+ {% csrf_token %} + {{ form.as_p }} + +
+ + +{% endblock content %} diff --git a/tests/app/rp/src/app.html b/tests/app/rp/src/app.html index 7a8d95035..889299587 100644 --- a/tests/app/rp/src/app.html +++ b/tests/app/rp/src/app.html @@ -40,42 +40,7 @@ %sveltekit.head% - -
-

Django OAuth Toolkit Test RP

- -
%sveltekit.body%
-
+
%sveltekit.body%
diff --git a/tests/app/rp/src/routes/+layout.svelte b/tests/app/rp/src/routes/+layout.svelte new file mode 100644 index 000000000..b631c5162 --- /dev/null +++ b/tests/app/rp/src/routes/+layout.svelte @@ -0,0 +1,105 @@ + + + + + + +
+ +
+ + diff --git a/tests/app/rp/src/routes/device/+page.svelte b/tests/app/rp/src/routes/device/+page.svelte new file mode 100644 index 000000000..cfa9555e7 --- /dev/null +++ b/tests/app/rp/src/routes/device/+page.svelte @@ -0,0 +1,490 @@ + + + + Device Authorization Flow Test + + +
+

Test the OAuth 2.0 Device Authorization Grant

+

+ This page demonstrates the Device Authorization Flow (RFC 8628), which is used by devices + with limited input capabilities (like smart TVs, IoT devices, etc.) to obtain OAuth tokens. + Do not use device-authorization in a browser, this is just an illustrative example to + streamline manual testing for maintainers. It shows how you'd need to implement the flow on + your device. Have a look at this full user journey test for an implementation in Python. +

+
+ +{#if status === 'idle'} +
+

Step 1: Initiate Authorization

+

Click the button below to start the device authorization flow.

+ +
+{/if} + +{#if status === 'authorizing'} +
+

Initiating...

+

Contacting the authorization server...

+
+
+{/if} + +{#if status === 'polling'} +
+

Step 2: Authorize the Device

+

+ Open the verification URL below in a new tab, enter the user code, and approve the + authorization. +

+ +
+
+ User Code: + {userCode} +
+
+ Verification URL: + + {verificationUri} + +
+
+ Expires in: + {expiresIn} seconds +
+
+ + + +
+
+

Polling for authorization... (checking every {interval} seconds)

+
+ + +
+{/if} + +{#if status === 'complete'} +
+

✓ Authorization Complete!

+

Successfully obtained an access token.

+ +
+
+ Token Type: + {tokenType} +
+
+ Expires In: + {expiresInToken} seconds +
+ {#if scope} +
+ Scope: + {scope} +
+ {/if} +
+ Access Token: + +
+ {#if refreshToken} +
+ Refresh Token: + +
+ {/if} +
+ + +
+{/if} + +{#if status === 'error'} +
+

Error

+

{errorMessage}

+ +
+{/if} + +
+

How it works

+
    +
  1. + Device requests authorization: The device sends a request to the authorization + server with its client ID. +
  2. +
  3. + Server returns codes: The server responds with a device code, user code, + and verification URI. +
  4. +
  5. + User authorizes: The user visits the verification URI on another device + (like a phone or computer), enters the user code, and approves the authorization. +
  6. +
  7. + Device polls for token: Meanwhile, the device polls the token endpoint using + the device code until the user completes authorization. +
  8. +
  9. + Token granted: Once the user approves, the polling request returns the access + token. +
  10. +
+
+ + diff --git a/tests/test_device.py b/tests/test_device.py new file mode 100644 index 000000000..727c81002 --- /dev/null +++ b/tests/test_device.py @@ -0,0 +1,769 @@ +from datetime import datetime, timedelta, timezone +from unittest import mock +from urllib.parse import urlencode + +import django.http.response +import pytest +from django import http +from django.conf import settings +from django.contrib.auth import get_user_model +from django.test import RequestFactory +from django.urls import reverse + +import oauth2_provider.models +from oauth2_provider.models import ( + get_access_token_model, + get_application_model, + get_device_grant_model, + get_refresh_token_model, +) +from oauth2_provider.utils import set_oauthlib_user_to_device_request_user + +from . import presets +from .common_testing import OAuth2ProviderTestCase as TestCase + + +Application = get_application_model() +AccessToken = get_access_token_model() +RefreshToken = get_refresh_token_model() +UserModel = get_user_model() +DeviceModel: oauth2_provider.models.DeviceGrant = get_device_grant_model() + + +@pytest.mark.usefixtures("oauth2_settings") +@pytest.mark.oauth2_settings(presets.DEFAULT_SCOPES_RW) +class DeviceFlowBaseTestCase(TestCase): + factory = RequestFactory() + + @classmethod + def setUpTestData(cls): + cls.test_user = UserModel.objects.create_user("test_user", "test@example.com", "123456") + cls.dev_user = UserModel.objects.create_user("dev_user", "dev@example.com", "123456") + + cls.application = Application.objects.create( + name="test_client_credentials_app", + user=cls.dev_user, + client_type=Application.CLIENT_PUBLIC, + authorization_grant_type=Application.GRANT_DEVICE_CODE, + client_secret="abcdefghijklmnopqrstuvwxyz1234567890", + ) + + def tearDown(self): + DeviceModel.objects.all().delete() + return super().tearDown() + + +class TestDeviceFlow(DeviceFlowBaseTestCase): + """ + The first 2 tests test the device flow in order + how the device flow works + """ + + @mock.patch( + "oauthlib.oauth2.rfc8628.endpoints.device_authorization.generate_token", + lambda: "abc", + ) + def test_device_flow_authorization_initiation(self): + """ + Tests the initial stage of the flow when the device sends its device authorization + request to the authorization server. + + Device Authorization Request(https://datatracker.ietf.org/doc/html/rfc8628#section-3.1) + + This request shape: + POST /device_authorization HTTP/1.1 + Host: server.example.com + Content-Type: application/x-www-form-urlencoded + + client_id=1406020730&scope=example_scope + + Should respond with this response shape: + Device Authorization Response (https://datatracker.ietf.org/doc/html/rfc8628#section-3.2) + { + "device_code": "GmRhmhcxhwAzkoEqiMEg_DnyEysNkuNhszIySk9eS", + "user_code": "WDJB-MJHT", + "verification_uri": "https://example.com/device", + "expires_in": 1800, + "interval": 5 + } + """ + + self.oauth2_settings.OAUTH_DEVICE_VERIFICATION_URI = "example.com/device" + self.oauth2_settings.OAUTH_DEVICE_USER_CODE_GENERATOR = lambda: "xyz" + + request_data: dict[str, str] = { + "client_id": self.application.client_id, + } + request_as_x_www_form_urlencoded: str = urlencode(request_data) + + response: django.http.response.JsonResponse = self.client.post( + reverse("oauth2_provider:device-authorization"), + data=request_as_x_www_form_urlencoded, + content_type="application/x-www-form-urlencoded", + ) + + assert response.status_code == 200 + + # let's make sure the device was created in the db + assert DeviceModel.objects.get(device_code="abc").status == DeviceModel.AUTHORIZATION_PENDING + + assert response.json() == { + "verification_uri": "example.com/device", + "expires_in": 1800, + "user_code": "xyz", + "device_code": "abc", + "interval": 5, + } + + @mock.patch( + "oauthlib.oauth2.rfc8628.endpoints.device_authorization.generate_token", + lambda: "abc", + ) + def test_device_flow_authorization_user_code_confirm_and_access_token(self): + """ + This is a full user journey test. + + The device initiates the flow by calling the /device-authorization endpoint and starts + polling the /authorize endpoint getting back error until the user approves in the + browser. + + In the meantime, the user visits the /device endpoint in their browsers to submit the + user code and approve, after which the /authorize returns the tokens to the device. + """ + + # ----------------------- + # 0: Setup device flow, where the device sends an authorization request and + # starts polling. The polling will fail because the user has not approved yet + # ----------------------- + self.oauth2_settings.OAUTH_DEVICE_VERIFICATION_URI = "example.com/device" + self.oauth2_settings.OAUTH_DEVICE_USER_CODE_GENERATOR = lambda: "xyz" + self.oauth2_settings.OAUTH_PRE_TOKEN_VALIDATION = [set_oauthlib_user_to_device_request_user] + + request_data: dict[str, str] = { + "client_id": self.application.client_id, + } + request_as_x_www_form_urlencoded: str = urlencode(request_data) + + device_authorization_response: http.response.JsonResponse = self.client.post( + reverse("oauth2_provider:device-authorization"), + data=request_as_x_www_form_urlencoded, + content_type="application/x-www-form-urlencoded", + ) + + assert device_authorization_response.__getitem__("content-type") == "application/json" + device = DeviceModel.objects.get(device_code="abc") + self.assertJSONEqual( + raw=device_authorization_response.content, + expected_data={ + "verification_uri": "example.com/device", + "expires_in": 1800, + "user_code": device.user_code, + "device_code": device.device_code, + "interval": 5, + }, + ) + + # Device polls /token and gets back error because the user hasn't approved yet + token_payload = { + "device_code": device.device_code, + "client_id": self.application.client_id, + "grant_type": "urn:ietf:params:oauth:grant-type:device_code", + } + + token_response: http.response.JsonResponse = self.client.post( + "/o/token/", + data=urlencode(token_payload), + content_type="application/x-www-form-urlencoded", + ) + # TokenView should always respond with application/json as it's meant to be + # consumed by devices. + assert token_response.__getitem__("content-type") == "application/json" + assert token_response.status_code == 400 + self.assertJSONEqual(raw=token_response.content, expected_data={"error": "authorization_pending"}) + + # /device and /device_confirm require a user to be logged in + # to access it + UserModel.objects.create_user( + username="test_user_device_flow", + email="test_device@example.com", + password="password123", + ) + self.client.login(username="test_user_device_flow", password="password123") + + # -------------------------------------------------------------------------------- + # 1. User visits the /device endpoint in their browsers and submits the user code + # submits wrong code then right code + # -------------------------------------------------------------------------------- + + # 1. User visits the /device endpoint in their browsers and submits the user code + # (GET Request to load it) + get_response = self.client.get(reverse("oauth2_provider:device")) + assert get_response.status_code == 200 + assert "form" in get_response.context # Ensure the form is rendered in the context + + # 1.1.0 User visits the /device endpoint in their browsers and submits wrong user code + self.assertContains( + self.client.post(reverse("oauth2_provider:device"), data={"user_code": "invalid_code"}), + status_code=200, + text="Incorrect user code", + count=1, + ) + + # Note: the device not being in the expected test covered in the other tests + + # 1.1.1: user submits valid user code + device_confirm_url = reverse( + "oauth2_provider:device-confirm", + kwargs={"user_code": "xyz", "client_id": self.application.client_id}, + ) + + self.assertRedirects( + response=self.client.post( + reverse("oauth2_provider:device"), + data={"user_code": "xyz"}, + ), + expected_url=device_confirm_url, + ) + + # -------------------------------------------------------------------------------- + # 2: We redirect to the accept/deny form (the user is still in their browser) + # and approves + # -------------------------------------------------------------------------------- + device_grant_status_url = reverse( + "oauth2_provider:device-grant-status", + kwargs={"user_code": "xyz", "client_id": self.application.client_id}, + ) + + self.assertRedirects( + response=self.client.post(device_confirm_url, data={"action": "accept"}), + expected_url=device_grant_status_url, + ) + + # -------------------------------------------------------------------------------- + # 3: We redirect to the device grant status page (the user is still in their browser) + # -------------------------------------------------------------------------------- + self.assertContains( + response=self.client.get(device_grant_status_url), + text="Device Authorized", + count=1, + ) + + device = DeviceModel.objects.get(device_code="abc") + assert device.status == device.AUTHORIZED + + # ------------------------- + # 4: Device polls /token successfully + # ------------------------- + token_payload = { + "device_code": device.device_code, + "client_id": self.application.client_id, + "grant_type": "urn:ietf:params:oauth:grant-type:device_code", + } + + token_response = self.client.post( + "/o/token/", + data=urlencode(token_payload), + content_type="application/x-www-form-urlencoded", + ) + # TokenView should always respond with application/json as it's meant to be + # consumed by devices. + assert token_response.__getitem__("content-type") == "application/json" + assert token_response.status_code == 200 + + token_data = token_response.json() + assert token_data == { + "access_token": mock.ANY, + "expires_in": 36000, + "token_type": "Bearer", + "scope": "read write", + "refresh_token": mock.ANY, + } + + # ensure the access token and refresh token have the same user as the device that just authenticated + access_token: oauth2_provider.models.AccessToken = AccessToken.objects.get( + token=token_data["access_token"] + ) + assert access_token.user == device.user + + refresh_token: oauth2_provider.models.RefreshToken = RefreshToken.objects.get( + token=token_data["refresh_token"] + ) + assert refresh_token.user == device.user + + def test_user_denies_access(self): + """ + This test asserts the when the user denies access, the state of the grant is saved + and the user is redirected to the page where they can see the "denied" state. + + The /token View returning the appropriate message for the "denied" state is covered + in test_token_view_returns_error_if_device_in_invalid_state. + """ + UserModel.objects.create_user( + username="test_user_device_flow", + email="test_device@example.com", + password="password123", + ) + self.client.login(username="test_user_device_flow", password="password123") + + device = DeviceModel( + client_id="client_id", + device_code="device_code", + user_code="user_code", + scope="scope", + expires=datetime.now() + timedelta(days=1), + status=DeviceModel.AUTHORIZATION_PENDING, + ) + device.save() + + device_confirm_url = reverse( + "oauth2_provider:device-confirm", + kwargs={"user_code": "user_code", "client_id": "client_id"}, + ) + + device_grant_status_url = reverse( + "oauth2_provider:device-grant-status", + kwargs={"user_code": "user_code", "client_id": "client_id"}, + ) + + self.assertRedirects( + response=self.client.post(device_confirm_url, data={"action": "deny"}), + expected_url=device_grant_status_url, + ) + + device.refresh_from_db() + assert device.status == device.DENIED + + def test_device_confirm_view_returns_400_on_incorrect_action(self): + """ + This test asserts that the confirm view returns 400 if action is not + "accept" or "deny". + """ + UserModel.objects.create_user( + username="test_user_device_flow", + email="test_device@example.com", + password="password123", + ) + self.client.login(username="test_user_device_flow", password="password123") + + device = DeviceModel( + client_id="client_id", + device_code="device_code", + user_code="user_code", + scope="scope", + expires=datetime.now() + timedelta(days=1), + status=DeviceModel.AUTHORIZATION_PENDING, + ) + device.save() + + device_confirm_url = reverse( + "oauth2_provider:device-confirm", + kwargs={"user_code": "user_code", "client_id": "client_id"}, + ) + response = self.client.post(device_confirm_url, data={"action": "inccorect_action"}) + + assert response.status_code == 400 + + def test_device_flow_authorization_device_invalid_state_returns_form_error(self): + """ + This test asserts that only devices in the expected state (authorization-pending) + can be approved/denied by the user. + """ + + UserModel.objects.create_user( + username="test_user_device_flow", + email="test_device@example.com", + password="password123", + ) + self.client.login(username="test_user_device_flow", password="password123") + + device = DeviceModel( + client_id="client_id", + device_code="device_code", + user_code="user_code", + scope="scope", + expires=datetime.now() + timedelta(days=1), + ) + device.save() + + # This simulates pytest.mark.parameterize, which unfortunately does not work with unittest + # and consequently with Django TestCase. + for invalid_state in ["authorized", "denied", "LOL_status"]: + # Set the device into an incorrect state. + device.status = invalid_state + device.save(update_fields=["status"]) + + self.assertContains( + response=self.client.post( + reverse("oauth2_provider:device"), + data={"user_code": "user_code"}, + ), + status_code=200, + text="User code has already been used", + count=1, + ) + + def test_device_flow_authorization_device_expired_returns_form_error(self): + """ + This test asserts that only devices in the expected state (authorization-pending) + can be approved/denied by the user. + """ + + UserModel.objects.create_user( + username="test_user_device_flow", + email="test_device@example.com", + password="password123", + ) + self.client.login(username="test_user_device_flow", password="password123") + + device = DeviceModel( + client_id="client_id", + device_code="device_code", + user_code="user_code", + scope="scope", + expires=datetime.now() + timedelta(seconds=-1), # <- essentially expired + ) + device.save() + + self.assertContains( + response=self.client.post( + reverse("oauth2_provider:device"), + data={"user_code": "user_code"}, + ), + status_code=200, + text="Expired user code", + count=1, + ) + + def test_token_view_returns_error_if_device_in_invalid_state(self): + """ + This test asserts that the token view returns the appropriate errors as specified + in https://datatracker.ietf.org/doc/html/rfc8628#section-3.5, in case the device + has not yet been approved by the user. + """ + + device = DeviceModel( + client_id="client_id", + device_code="device_code", + user_code="user_code", + scope="scope", + expires=datetime.now() + timedelta(seconds=60), + ) + device.save() + + token_payload = { + "device_code": "device_code", + "client_id": "client_id", + "grant_type": "urn:ietf:params:oauth:grant-type:device_code", + } + + testcases = [ + ("authorization-pending", '{"error": "authorization_pending"}', 400), + ("expired", '{"error": "expired_token"}', 400), + ("denied", '{"error": "access_denied"}', 400), + ("LOL_status", '{"error": "internal_error"}', 500), + ] + for invalid_state, expected_error_message, expected_error_code in testcases: + device.status = invalid_state + device.save(update_fields=["status"]) + + response = self.client.post( + "/o/token/", + data=urlencode(token_payload), + content_type="application/x-www-form-urlencoded", + ) + self.assertContains( + response=response, + status_code=expected_error_code, + text=expected_error_message, + count=1, + ) + # TokenView should always respond with application/json as it's meant to be + # consumed by devices. + self.assertEqual(response.__getitem__("content-type"), "application/json") + + def test_token_view_returns_404_error_if_device_not_found(self): + device = DeviceModel( + client_id="client_id", + device_code="device_code", + user_code="user_code", + scope="scope", + expires=datetime.now() + timedelta(seconds=60), + ) + device.save() + + token_payload = { + "device_code": "another_device_code", + "client_id": "client_id", + "grant_type": "urn:ietf:params:oauth:grant-type:device_code", + } + + response = self.client.post( + "/o/token/", + data=urlencode(token_payload), + content_type="application/x-www-form-urlencoded", + ) + self.assertContains( + response=response, + status_code=404, + text="device_not_found", + count=1, + ) + # TokenView should always respond with application/json as it's meant to be + # consumed by devices. + self.assertEqual(response.__getitem__("content-type"), "application/json") + + def test_token_view_status_equals_what_oauthlib_token_response_method_returns(self): + """ + Tests the use case where oauthlib create_token_response returns a status different + than 200. + """ + + class MockOauthlibCoreClass: + def create_token_response(self, _): + return "url", {"headers_are_ignored": True}, '{"Key": "Value"}', 299 + + device = DeviceModel( + client_id="client_id", + device_code="device_code", + user_code="user_code", + scope="scope", + expires=datetime.now() + timedelta(seconds=60), + status="authorized", + ) + device.save() + + token_payload = { + "device_code": "device_code", + "client_id": "client_id", + "grant_type": "urn:ietf:params:oauth:grant-type:device_code", + } + + with mock.patch( + "oauth2_provider.views.mixins.OAuthLibMixin.get_oauthlib_core", MockOauthlibCoreClass + ): + response = self.client.post( + "/o/token/", + data=urlencode(token_payload), + content_type="application/x-www-form-urlencoded", + ) + + self.assertEqual(response["content-type"], "application/json") + self.assertContains( + response=response, + status_code=299, + text='{"Key": "Value"}', + count=1, + ) + assert not response.has_header("headers_are_ignored") + + @mock.patch( + "oauthlib.oauth2.rfc8628.endpoints.device_authorization.generate_token", + lambda: "abc", + ) + def test_device_polling_interval_can_be_changed(self): + """ + Tests the device polling rate(interval) can be changed to something other than the default + of 5 seconds. + """ + + self.oauth2_settings.OAUTH_DEVICE_VERIFICATION_URI = "example.com/device" + self.oauth2_settings.OAUTH_DEVICE_USER_CODE_GENERATOR = lambda: "xyz" + + self.oauth2_settings.DEVICE_FLOW_INTERVAL = 10 + + request_data: dict[str, str] = { + "client_id": self.application.client_id, + } + request_as_x_www_form_urlencoded: str = urlencode(request_data) + + response: django.http.response.JsonResponse = self.client.post( + reverse("oauth2_provider:device-authorization"), + data=request_as_x_www_form_urlencoded, + content_type="application/x-www-form-urlencoded", + ) + + assert response.status_code == 200 + + assert response.json() == { + "verification_uri": "example.com/device", + "expires_in": 1800, + "user_code": "xyz", + "device_code": "abc", + "interval": 10, + } + + def test_incorrect_client_id_sent(self): + """ + Ensure the correct error is returned when an invalid client is sent + """ + request_data: dict[str, str] = { + "client_id": "client_id_that_does_not_exist", + } + request_as_x_www_form_urlencoded: str = urlencode(request_data) + + response: django.http.response.JsonResponse = self.client.post( + reverse("oauth2_provider:device-authorization"), + data=request_as_x_www_form_urlencoded, + content_type="application/x-www-form-urlencoded", + ) + + assert response.status_code == 400 + + assert response.json() == { + "error": "invalid_request", + "error_description": "Invalid client_id parameter value.", + } + + def test_missing_client_id(self): + """ + Ensure the correct error is returned when the client id is missing. + """ + request_data: dict[str, str] = { + "not_client_id": "client_id_that_does_not_exist", + } + request_as_x_www_form_urlencoded: str = urlencode(request_data) + + response: django.http.response.JsonResponse = self.client.post( + reverse("oauth2_provider:device-authorization"), + data=request_as_x_www_form_urlencoded, + content_type="application/x-www-form-urlencoded", + ) + + assert response.status_code == 400 + + assert response.json() == { + "error": "invalid_request", + "error_description": "Missing client_id parameter.", + } + + def test_device_confirm_and_user_code_views_require_login(self): + URLs = [ + reverse("oauth2_provider:device-confirm", kwargs={"user_code": None, "client_id": "abc"}), + reverse("oauth2_provider:device-confirm", kwargs={"user_code": "abc", "client_id": "abc"}), + reverse("oauth2_provider:device"), + ] + + for url in URLs: + r = self.client.get(url) + assert r.status_code == 302 + assert r["Location"] == f"{settings.LOGIN_URL}?next={url}" + + r = self.client.post(url) + assert r.status_code == 302 + assert r["Location"] == f"{settings.LOGIN_URL}?next={url}" + + def test_device_confirm_view_GET_returns_404_when_device_does_not_exist(self): + UserModel.objects.create_user( + username="test_user_device_flow", + email="test_device@example.com", + password="password123", + ) + self.client.login(username="test_user_device_flow", password="password123") + + device = DeviceModel( + client_id="client_id", + device_code="device_code", + user_code="user_code", + scope="scope", + expires=datetime.now(), + ) + device.save() + + self.assertContains( + response=self.client.get( + reverse( + "oauth2_provider:device-confirm", + kwargs={"user_code": "not_user_code", "client_id": "not_client_id"}, + ) + ), + status_code=404, + text="The requested resource was not found on this server.", + ) + + # Asserts for valid user_code and client_id but invalid states + for invalid_state in ["authorized", "denied", "expired"]: + device.status = invalid_state + device.save(update_fields=["status"]) + + self.assertContains( + response=self.client.get( + reverse( + "oauth2_provider:device-confirm", + kwargs={"user_code": "not_user_code", "client_id": "client_id"}, + ) + ), + status_code=404, + text="The requested resource was not found on this server.", + ) + + def test_device_confirm_view_POST_returns_404_when_device_does_not_exist(self): + UserModel.objects.create_user( + username="test_user_device_flow", + email="test_device@example.com", + password="password123", + ) + self.client.login(username="test_user_device_flow", password="password123") + + device = DeviceModel( + client_id="client_id", + device_code="device_code", + user_code="user_code", + scope="scope", + expires=datetime.now(), + ) + device.save() + + self.assertContains( + response=self.client.post( + reverse( + "oauth2_provider:device-confirm", + kwargs={"user_code": "not_user_code", "client_id": "client_id"}, + ), + data={"action": "accept"}, + ), + status_code=404, + text="The requested resource was not found on this server.", + count=1, + ) + + # Asserts for valid user_code and client_id but invalid states + for invalid_state in ["authorized", "denied", "expired"]: + device.status = invalid_state + device.save(update_fields=["status"]) + + self.assertContains( + response=self.client.post( + reverse( + "oauth2_provider:device-confirm", + kwargs={"user_code": "user_code", "client_id": "client_id"}, + ), + data={"action": "accept"}, + ), + status_code=404, + text="The requested resource was not found on this server.", + count=1, + ) + + def test_device_is_expired_method_sets_status_to_expired_if_deadline_passed(self): + device = DeviceModel( + client_id="client_id", + device_code="device_code", + user_code="user_code", + scope="scope", + expires=datetime.now(tz=timezone.utc) + timedelta(seconds=-1), # <- essentially expired + ) + device.save() + + assert device.status == device.AUTHORIZATION_PENDING # default value + + # call is_expired() which should update the state + is_expired = device.is_expired() + + assert is_expired + assert device.status == device.EXPIRED + + # calling again is_expired() should return true and not change the state + is_expired = device.is_expired() + + assert is_expired + assert device.status == device.EXPIRED diff --git a/tests/test_oauth2_validators.py b/tests/test_oauth2_validators.py index 14c74506e..7e7e46de7 100644 --- a/tests/test_oauth2_validators.py +++ b/tests/test_oauth2_validators.py @@ -180,6 +180,12 @@ def test_authenticate_basic_auth_not_utf8(self): self.request.headers = {"HTTP_AUTHORIZATION": "Basic test"} self.assertFalse(self.validator._authenticate_basic_auth(self.request)) + def test_authenticate_basic_auth_public_app_with_device_code(self): + self.request.grant_type = "urn:ietf:params:oauth:grant-type:device_code" + self.request.headers = get_basic_auth_header("client_id", CLEARTEXT_SECRET) + self.application.client_type = Application.CLIENT_PUBLIC + self.assertTrue(self.validator._authenticate_basic_auth(self.request)) + def test_authenticate_check_secret(self): hashed = make_password(CLEARTEXT_SECRET) self.assertTrue(self.validator._check_secret(CLEARTEXT_SECRET, CLEARTEXT_SECRET)) diff --git a/tests/test_utils.py b/tests/test_utils.py index 2c319b6ea..eef4b985c 100644 --- a/tests/test_utils.py +++ b/tests/test_utils.py @@ -1,3 +1,5 @@ +import pytest + from oauth2_provider import utils @@ -25,3 +27,24 @@ def test_jwk_from_pem_caches_jwk(): jwk3 = utils.jwk_from_pem(a_different_tiny_rsa_key) assert jwk3 is not jwk1 + + +def test_user_code_generator(): + # Default argument, 8 characters + user_code = utils.user_code_generator() + assert isinstance(user_code, str) + assert len(user_code) == 8 + + for character in user_code: + assert character >= "0" + assert character <= "V" + + another_user_code = utils.user_code_generator() + assert another_user_code != user_code + + shorter_user_code = utils.user_code_generator(user_code_length=1) + assert len(shorter_user_code) == 1 + + with pytest.raises(ValueError): + utils.user_code_generator(user_code_length=0) + utils.user_code_generator(user_code_length=-1) diff --git a/tox.ini b/tox.ini index 0a85f5fb8..29e93a2ae 100644 --- a/tox.ini +++ b/tox.ini @@ -46,7 +46,7 @@ deps = dj52: Django>=5.2,<6.0 djmain: https://github.com/django/django/archive/main.tar.gz djangorestframework - oauthlib>=3.2.2 + oauthlib>=3.3.0 jwcrypto coverage pytest @@ -79,7 +79,7 @@ commands = deps = Jinja2<3.1 sphinx<3 - oauthlib>=3.2.2 + oauthlib>=3.3.0 m2r>=0.2.1 mistune<2 sphinx-rtd-theme diff --git a/uv.lock b/uv.lock index 43764ff93..d5f28ba2c 100644 --- a/uv.lock +++ b/uv.lock @@ -1,6 +1,6 @@ version = 1 revision = 3 -requires-python = ">=3.8, <3.14" +requires-python = ">=3.8, <=3.14" resolution-markers = [ "python_full_version >= '3.11'", "python_full_version == '3.10.*'", @@ -260,6 +260,28 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/eb/6d/bf9bda840d5f1dfdbf0feca87fbdb64a918a69bca42cfa0ba7b137c48cb8/cffi-2.0.0-cp313-cp313-win32.whl", hash = "sha256:74a03b9698e198d47562765773b4a8309919089150a0bb17d829ad7b44b60d27", size = 172909, upload-time = "2025-09-08T23:23:14.32Z" }, { url = "https://files.pythonhosted.org/packages/37/18/6519e1ee6f5a1e579e04b9ddb6f1676c17368a7aba48299c3759bbc3c8b3/cffi-2.0.0-cp313-cp313-win_amd64.whl", hash = "sha256:19f705ada2530c1167abacb171925dd886168931e0a7b78f5bffcae5c6b5be75", size = 183402, upload-time = "2025-09-08T23:23:15.535Z" }, { url = "https://files.pythonhosted.org/packages/cb/0e/02ceeec9a7d6ee63bb596121c2c8e9b3a9e150936f4fbef6ca1943e6137c/cffi-2.0.0-cp313-cp313-win_arm64.whl", hash = "sha256:256f80b80ca3853f90c21b23ee78cd008713787b1b1e93eae9f3d6a7134abd91", size = 177780, upload-time = "2025-09-08T23:23:16.761Z" }, + { url = "https://files.pythonhosted.org/packages/92/c4/3ce07396253a83250ee98564f8d7e9789fab8e58858f35d07a9a2c78de9f/cffi-2.0.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:fc33c5141b55ed366cfaad382df24fe7dcbc686de5be719b207bb248e3053dc5", size = 185320, upload-time = "2025-09-08T23:23:18.087Z" }, + { url = "https://files.pythonhosted.org/packages/59/dd/27e9fa567a23931c838c6b02d0764611c62290062a6d4e8ff7863daf9730/cffi-2.0.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c654de545946e0db659b3400168c9ad31b5d29593291482c43e3564effbcee13", size = 181487, upload-time = "2025-09-08T23:23:19.622Z" }, + { url = "https://files.pythonhosted.org/packages/d6/43/0e822876f87ea8a4ef95442c3d766a06a51fc5298823f884ef87aaad168c/cffi-2.0.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:24b6f81f1983e6df8db3adc38562c83f7d4a0c36162885ec7f7b77c7dcbec97b", size = 220049, upload-time = "2025-09-08T23:23:20.853Z" }, + { url = "https://files.pythonhosted.org/packages/b4/89/76799151d9c2d2d1ead63c2429da9ea9d7aac304603de0c6e8764e6e8e70/cffi-2.0.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:12873ca6cb9b0f0d3a0da705d6086fe911591737a59f28b7936bdfed27c0d47c", size = 207793, upload-time = "2025-09-08T23:23:22.08Z" }, + { url = "https://files.pythonhosted.org/packages/bb/dd/3465b14bb9e24ee24cb88c9e3730f6de63111fffe513492bf8c808a3547e/cffi-2.0.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:d9b97165e8aed9272a6bb17c01e3cc5871a594a446ebedc996e2397a1c1ea8ef", size = 206300, upload-time = "2025-09-08T23:23:23.314Z" }, + { url = "https://files.pythonhosted.org/packages/47/d9/d83e293854571c877a92da46fdec39158f8d7e68da75bf73581225d28e90/cffi-2.0.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:afb8db5439b81cf9c9d0c80404b60c3cc9c3add93e114dcae767f1477cb53775", size = 219244, upload-time = "2025-09-08T23:23:24.541Z" }, + { url = "https://files.pythonhosted.org/packages/2b/0f/1f177e3683aead2bb00f7679a16451d302c436b5cbf2505f0ea8146ef59e/cffi-2.0.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:737fe7d37e1a1bffe70bd5754ea763a62a066dc5913ca57e957824b72a85e205", size = 222828, upload-time = "2025-09-08T23:23:26.143Z" }, + { url = "https://files.pythonhosted.org/packages/c6/0f/cafacebd4b040e3119dcb32fed8bdef8dfe94da653155f9d0b9dc660166e/cffi-2.0.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:38100abb9d1b1435bc4cc340bb4489635dc2f0da7456590877030c9b3d40b0c1", size = 220926, upload-time = "2025-09-08T23:23:27.873Z" }, + { url = "https://files.pythonhosted.org/packages/3e/aa/df335faa45b395396fcbc03de2dfcab242cd61a9900e914fe682a59170b1/cffi-2.0.0-cp314-cp314-win32.whl", hash = "sha256:087067fa8953339c723661eda6b54bc98c5625757ea62e95eb4898ad5e776e9f", size = 175328, upload-time = "2025-09-08T23:23:44.61Z" }, + { url = "https://files.pythonhosted.org/packages/bb/92/882c2d30831744296ce713f0feb4c1cd30f346ef747b530b5318715cc367/cffi-2.0.0-cp314-cp314-win_amd64.whl", hash = "sha256:203a48d1fb583fc7d78a4c6655692963b860a417c0528492a6bc21f1aaefab25", size = 185650, upload-time = "2025-09-08T23:23:45.848Z" }, + { url = "https://files.pythonhosted.org/packages/9f/2c/98ece204b9d35a7366b5b2c6539c350313ca13932143e79dc133ba757104/cffi-2.0.0-cp314-cp314-win_arm64.whl", hash = "sha256:dbd5c7a25a7cb98f5ca55d258b103a2054f859a46ae11aaf23134f9cc0d356ad", size = 180687, upload-time = "2025-09-08T23:23:47.105Z" }, + { url = "https://files.pythonhosted.org/packages/3e/61/c768e4d548bfa607abcda77423448df8c471f25dbe64fb2ef6d555eae006/cffi-2.0.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:9a67fc9e8eb39039280526379fb3a70023d77caec1852002b4da7e8b270c4dd9", size = 188773, upload-time = "2025-09-08T23:23:29.347Z" }, + { url = "https://files.pythonhosted.org/packages/2c/ea/5f76bce7cf6fcd0ab1a1058b5af899bfbef198bea4d5686da88471ea0336/cffi-2.0.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7a66c7204d8869299919db4d5069a82f1561581af12b11b3c9f48c584eb8743d", size = 185013, upload-time = "2025-09-08T23:23:30.63Z" }, + { url = "https://files.pythonhosted.org/packages/be/b4/c56878d0d1755cf9caa54ba71e5d049479c52f9e4afc230f06822162ab2f/cffi-2.0.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:7cc09976e8b56f8cebd752f7113ad07752461f48a58cbba644139015ac24954c", size = 221593, upload-time = "2025-09-08T23:23:31.91Z" }, + { url = "https://files.pythonhosted.org/packages/e0/0d/eb704606dfe8033e7128df5e90fee946bbcb64a04fcdaa97321309004000/cffi-2.0.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:92b68146a71df78564e4ef48af17551a5ddd142e5190cdf2c5624d0c3ff5b2e8", size = 209354, upload-time = "2025-09-08T23:23:33.214Z" }, + { url = "https://files.pythonhosted.org/packages/d8/19/3c435d727b368ca475fb8742ab97c9cb13a0de600ce86f62eab7fa3eea60/cffi-2.0.0-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:b1e74d11748e7e98e2f426ab176d4ed720a64412b6a15054378afdb71e0f37dc", size = 208480, upload-time = "2025-09-08T23:23:34.495Z" }, + { url = "https://files.pythonhosted.org/packages/d0/44/681604464ed9541673e486521497406fadcc15b5217c3e326b061696899a/cffi-2.0.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:28a3a209b96630bca57cce802da70c266eb08c6e97e5afd61a75611ee6c64592", size = 221584, upload-time = "2025-09-08T23:23:36.096Z" }, + { url = "https://files.pythonhosted.org/packages/25/8e/342a504ff018a2825d395d44d63a767dd8ebc927ebda557fecdaca3ac33a/cffi-2.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:7553fb2090d71822f02c629afe6042c299edf91ba1bf94951165613553984512", size = 224443, upload-time = "2025-09-08T23:23:37.328Z" }, + { url = "https://files.pythonhosted.org/packages/e1/5e/b666bacbbc60fbf415ba9988324a132c9a7a0448a9a8f125074671c0f2c3/cffi-2.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:6c6c373cfc5c83a975506110d17457138c8c63016b563cc9ed6e056a82f13ce4", size = 223437, upload-time = "2025-09-08T23:23:38.945Z" }, + { url = "https://files.pythonhosted.org/packages/a0/1d/ec1a60bd1a10daa292d3cd6bb0b359a81607154fb8165f3ec95fe003b85c/cffi-2.0.0-cp314-cp314t-win32.whl", hash = "sha256:1fc9ea04857caf665289b7a75923f2c6ed559b8298a1b8c49e59f7dd95c8481e", size = 180487, upload-time = "2025-09-08T23:23:40.423Z" }, + { url = "https://files.pythonhosted.org/packages/bf/41/4c1168c74fac325c0c8156f04b6749c8b6a8f405bbf91413ba088359f60d/cffi-2.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:d68b6cef7827e8641e8ef16f4494edda8b36104d79773a334beaa1e3521430f6", size = 191726, upload-time = "2025-09-08T23:23:41.742Z" }, + { url = "https://files.pythonhosted.org/packages/ae/3a/dbeec9d1ee0844c679f6bb5d6ad4e9f198b1224f4e7a32825f47f6192b0c/cffi-2.0.0-cp314-cp314t-win_arm64.whl", hash = "sha256:0a1527a803f0a659de1af2e1fd700213caba79377e27e4693648c2923da066f9", size = 184195, upload-time = "2025-09-08T23:23:43.004Z" }, { url = "https://files.pythonhosted.org/packages/c0/cc/08ed5a43f2996a16b462f64a7055c6e962803534924b9b2f1371d8c00b7b/cffi-2.0.0-cp39-cp39-macosx_10_13_x86_64.whl", hash = "sha256:fe562eb1a64e67dd297ccc4f5addea2501664954f2692b69a76449ec7913ecbf", size = 184288, upload-time = "2025-09-08T23:23:48.404Z" }, { url = "https://files.pythonhosted.org/packages/3d/de/38d9726324e127f727b4ecc376bc85e505bfe61ef130eaf3f290c6847dd4/cffi-2.0.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:de8dad4425a6ca6e4e5e297b27b5c824ecc7581910bf9aee86cb6835e6812aa7", size = 180509, upload-time = "2025-09-08T23:23:49.73Z" }, { url = "https://files.pythonhosted.org/packages/9b/13/c92e36358fbcc39cf0962e83223c9522154ee8630e1df7c0b3a39a8124e2/cffi-2.0.0-cp39-cp39-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:4647afc2f90d1ddd33441e5b0e85b16b12ddec4fca55f0d9671fef036ecca27c", size = 208813, upload-time = "2025-09-08T23:23:51.263Z" }, @@ -344,6 +366,22 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/89/66/c7a9e1b7429be72123441bfdbaf2bc13faab3f90b933f664db506dea5915/charset_normalizer-3.4.4-cp313-cp313-win32.whl", hash = "sha256:9b35f4c90079ff2e2edc5b26c0c77925e5d2d255c42c74fdb70fb49b172726ac", size = 99404, upload-time = "2025-10-14T04:41:29.95Z" }, { url = "https://files.pythonhosted.org/packages/c4/26/b9924fa27db384bdcd97ab83b4f0a8058d96ad9626ead570674d5e737d90/charset_normalizer-3.4.4-cp313-cp313-win_amd64.whl", hash = "sha256:b435cba5f4f750aa6c0a0d92c541fb79f69a387c91e61f1795227e4ed9cece14", size = 107092, upload-time = "2025-10-14T04:41:31.188Z" }, { url = "https://files.pythonhosted.org/packages/af/8f/3ed4bfa0c0c72a7ca17f0380cd9e4dd842b09f664e780c13cff1dcf2ef1b/charset_normalizer-3.4.4-cp313-cp313-win_arm64.whl", hash = "sha256:542d2cee80be6f80247095cc36c418f7bddd14f4a6de45af91dfad36d817bba2", size = 100408, upload-time = "2025-10-14T04:41:32.624Z" }, + { url = "https://files.pythonhosted.org/packages/2a/35/7051599bd493e62411d6ede36fd5af83a38f37c4767b92884df7301db25d/charset_normalizer-3.4.4-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:da3326d9e65ef63a817ecbcc0df6e94463713b754fe293eaa03da99befb9a5bd", size = 207746, upload-time = "2025-10-14T04:41:33.773Z" }, + { url = "https://files.pythonhosted.org/packages/10/9a/97c8d48ef10d6cd4fcead2415523221624bf58bcf68a802721a6bc807c8f/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8af65f14dc14a79b924524b1e7fffe304517b2bff5a58bf64f30b98bbc5079eb", size = 147889, upload-time = "2025-10-14T04:41:34.897Z" }, + { url = "https://files.pythonhosted.org/packages/10/bf/979224a919a1b606c82bd2c5fa49b5c6d5727aa47b4312bb27b1734f53cd/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:74664978bb272435107de04e36db5a9735e78232b85b77d45cfb38f758efd33e", size = 143641, upload-time = "2025-10-14T04:41:36.116Z" }, + { url = "https://files.pythonhosted.org/packages/ba/33/0ad65587441fc730dc7bd90e9716b30b4702dc7b617e6ba4997dc8651495/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:752944c7ffbfdd10c074dc58ec2d5a8a4cd9493b314d367c14d24c17684ddd14", size = 160779, upload-time = "2025-10-14T04:41:37.229Z" }, + { url = "https://files.pythonhosted.org/packages/67/ed/331d6b249259ee71ddea93f6f2f0a56cfebd46938bde6fcc6f7b9a3d0e09/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d1f13550535ad8cff21b8d757a3257963e951d96e20ec82ab44bc64aeb62a191", size = 159035, upload-time = "2025-10-14T04:41:38.368Z" }, + { url = "https://files.pythonhosted.org/packages/67/ff/f6b948ca32e4f2a4576aa129d8bed61f2e0543bf9f5f2b7fc3758ed005c9/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ecaae4149d99b1c9e7b88bb03e3221956f68fd6d50be2ef061b2381b61d20838", size = 152542, upload-time = "2025-10-14T04:41:39.862Z" }, + { url = "https://files.pythonhosted.org/packages/16/85/276033dcbcc369eb176594de22728541a925b2632f9716428c851b149e83/charset_normalizer-3.4.4-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:cb6254dc36b47a990e59e1068afacdcd02958bdcce30bb50cc1700a8b9d624a6", size = 149524, upload-time = "2025-10-14T04:41:41.319Z" }, + { url = "https://files.pythonhosted.org/packages/9e/f2/6a2a1f722b6aba37050e626530a46a68f74e63683947a8acff92569f979a/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:c8ae8a0f02f57a6e61203a31428fa1d677cbe50c93622b4149d5c0f319c1d19e", size = 150395, upload-time = "2025-10-14T04:41:42.539Z" }, + { url = "https://files.pythonhosted.org/packages/60/bb/2186cb2f2bbaea6338cad15ce23a67f9b0672929744381e28b0592676824/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:47cc91b2f4dd2833fddaedd2893006b0106129d4b94fdb6af1f4ce5a9965577c", size = 143680, upload-time = "2025-10-14T04:41:43.661Z" }, + { url = "https://files.pythonhosted.org/packages/7d/a5/bf6f13b772fbb2a90360eb620d52ed8f796f3c5caee8398c3b2eb7b1c60d/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:82004af6c302b5d3ab2cfc4cc5f29db16123b1a8417f2e25f9066f91d4411090", size = 162045, upload-time = "2025-10-14T04:41:44.821Z" }, + { url = "https://files.pythonhosted.org/packages/df/c5/d1be898bf0dc3ef9030c3825e5d3b83f2c528d207d246cbabe245966808d/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:2b7d8f6c26245217bd2ad053761201e9f9680f8ce52f0fcd8d0755aeae5b2152", size = 149687, upload-time = "2025-10-14T04:41:46.442Z" }, + { url = "https://files.pythonhosted.org/packages/a5/42/90c1f7b9341eef50c8a1cb3f098ac43b0508413f33affd762855f67a410e/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:799a7a5e4fb2d5898c60b640fd4981d6a25f1c11790935a44ce38c54e985f828", size = 160014, upload-time = "2025-10-14T04:41:47.631Z" }, + { url = "https://files.pythonhosted.org/packages/76/be/4d3ee471e8145d12795ab655ece37baed0929462a86e72372fd25859047c/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:99ae2cffebb06e6c22bdc25801d7b30f503cc87dbd283479e7b606f70aff57ec", size = 154044, upload-time = "2025-10-14T04:41:48.81Z" }, + { url = "https://files.pythonhosted.org/packages/b0/6f/8f7af07237c34a1defe7defc565a9bc1807762f672c0fde711a4b22bf9c0/charset_normalizer-3.4.4-cp314-cp314-win32.whl", hash = "sha256:f9d332f8c2a2fcbffe1378594431458ddbef721c1769d78e2cbc06280d8155f9", size = 99940, upload-time = "2025-10-14T04:41:49.946Z" }, + { url = "https://files.pythonhosted.org/packages/4b/51/8ade005e5ca5b0d80fb4aff72a3775b325bdc3d27408c8113811a7cbe640/charset_normalizer-3.4.4-cp314-cp314-win_amd64.whl", hash = "sha256:8a6562c3700cce886c5be75ade4a5db4214fda19fede41d9792d100288d8f94c", size = 107104, upload-time = "2025-10-14T04:41:51.051Z" }, + { url = "https://files.pythonhosted.org/packages/da/5f/6b8f83a55bb8278772c5ae54a577f3099025f9ade59d0136ac24a0df4bde/charset_normalizer-3.4.4-cp314-cp314-win_arm64.whl", hash = "sha256:de00632ca48df9daf77a2c65a484531649261ec9f25489917f09e455cb09ddb2", size = 100743, upload-time = "2025-10-14T04:41:52.122Z" }, { url = "https://files.pythonhosted.org/packages/0a/4e/3926a1c11f0433791985727965263f788af00db3482d89a7545ca5ecc921/charset_normalizer-3.4.4-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:ce8a0633f41a967713a59c4139d29110c07e826d131a316b50ce11b1d79b4f84", size = 198599, upload-time = "2025-10-14T04:41:53.213Z" }, { url = "https://files.pythonhosted.org/packages/ec/7c/b92d1d1dcffc34592e71ea19c882b6709e43d20fa498042dea8b815638d7/charset_normalizer-3.4.4-cp38-cp38-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:eaabd426fe94daf8fd157c32e571c85cb12e66692f15516a83a03264b08d06c3", size = 143090, upload-time = "2025-10-14T04:41:54.385Z" }, { url = "https://files.pythonhosted.org/packages/84/ce/61a28d3bb77281eb24107b937a497f3c43089326d27832a63dcedaab0478/charset_normalizer-3.4.4-cp38-cp38-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:c4ef880e27901b6cc782f1b95f82da9313c0eb95c3af699103088fa0ac3ce9ac", size = 139490, upload-time = "2025-10-14T04:41:55.551Z" }, @@ -547,6 +585,32 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/ee/51/a03bec00d37faaa891b3ff7387192cef20f01604e5283a5fabc95346befa/coverage-7.10.7-cp313-cp313t-win32.whl", hash = "sha256:2a78cd46550081a7909b3329e2266204d584866e8d97b898cd7fb5ac8d888b1a", size = 221403, upload-time = "2025-09-21T20:02:37.034Z" }, { url = "https://files.pythonhosted.org/packages/53/22/3cf25d614e64bf6d8e59c7c669b20d6d940bb337bdee5900b9ca41c820bb/coverage-7.10.7-cp313-cp313t-win_amd64.whl", hash = "sha256:33a5e6396ab684cb43dc7befa386258acb2d7fae7f67330ebb85ba4ea27938eb", size = 222469, upload-time = "2025-09-21T20:02:39.011Z" }, { url = "https://files.pythonhosted.org/packages/49/a1/00164f6d30d8a01c3c9c48418a7a5be394de5349b421b9ee019f380df2a0/coverage-7.10.7-cp313-cp313t-win_arm64.whl", hash = "sha256:86b0e7308289ddde73d863b7683f596d8d21c7d8664ce1dee061d0bcf3fbb4bb", size = 220731, upload-time = "2025-09-21T20:02:40.939Z" }, + { url = "https://files.pythonhosted.org/packages/23/9c/5844ab4ca6a4dd97a1850e030a15ec7d292b5c5cb93082979225126e35dd/coverage-7.10.7-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:b06f260b16ead11643a5a9f955bd4b5fd76c1a4c6796aeade8520095b75de520", size = 218302, upload-time = "2025-09-21T20:02:42.527Z" }, + { url = "https://files.pythonhosted.org/packages/f0/89/673f6514b0961d1f0e20ddc242e9342f6da21eaba3489901b565c0689f34/coverage-7.10.7-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:212f8f2e0612778f09c55dd4872cb1f64a1f2b074393d139278ce902064d5b32", size = 218578, upload-time = "2025-09-21T20:02:44.468Z" }, + { url = "https://files.pythonhosted.org/packages/05/e8/261cae479e85232828fb17ad536765c88dd818c8470aca690b0ac6feeaa3/coverage-7.10.7-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:3445258bcded7d4aa630ab8296dea4d3f15a255588dd535f980c193ab6b95f3f", size = 249629, upload-time = "2025-09-21T20:02:46.503Z" }, + { url = "https://files.pythonhosted.org/packages/82/62/14ed6546d0207e6eda876434e3e8475a3e9adbe32110ce896c9e0c06bb9a/coverage-7.10.7-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:bb45474711ba385c46a0bfe696c695a929ae69ac636cda8f532be9e8c93d720a", size = 252162, upload-time = "2025-09-21T20:02:48.689Z" }, + { url = "https://files.pythonhosted.org/packages/ff/49/07f00db9ac6478e4358165a08fb41b469a1b053212e8a00cb02f0d27a05f/coverage-7.10.7-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:813922f35bd800dca9994c5971883cbc0d291128a5de6b167c7aa697fcf59360", size = 253517, upload-time = "2025-09-21T20:02:50.31Z" }, + { url = "https://files.pythonhosted.org/packages/a2/59/c5201c62dbf165dfbc91460f6dbbaa85a8b82cfa6131ac45d6c1bfb52deb/coverage-7.10.7-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:93c1b03552081b2a4423091d6fb3787265b8f86af404cff98d1b5342713bdd69", size = 249632, upload-time = "2025-09-21T20:02:51.971Z" }, + { url = "https://files.pythonhosted.org/packages/07/ae/5920097195291a51fb00b3a70b9bbd2edbfe3c84876a1762bd1ef1565ebc/coverage-7.10.7-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:cc87dd1b6eaf0b848eebb1c86469b9f72a1891cb42ac7adcfbce75eadb13dd14", size = 251520, upload-time = "2025-09-21T20:02:53.858Z" }, + { url = "https://files.pythonhosted.org/packages/b9/3c/a815dde77a2981f5743a60b63df31cb322c944843e57dbd579326625a413/coverage-7.10.7-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:39508ffda4f343c35f3236fe8d1a6634a51f4581226a1262769d7f970e73bffe", size = 249455, upload-time = "2025-09-21T20:02:55.807Z" }, + { url = "https://files.pythonhosted.org/packages/aa/99/f5cdd8421ea656abefb6c0ce92556709db2265c41e8f9fc6c8ae0f7824c9/coverage-7.10.7-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:925a1edf3d810537c5a3abe78ec5530160c5f9a26b1f4270b40e62cc79304a1e", size = 249287, upload-time = "2025-09-21T20:02:57.784Z" }, + { url = "https://files.pythonhosted.org/packages/c3/7a/e9a2da6a1fc5d007dd51fca083a663ab930a8c4d149c087732a5dbaa0029/coverage-7.10.7-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:2c8b9a0636f94c43cd3576811e05b89aa9bc2d0a85137affc544ae5cb0e4bfbd", size = 250946, upload-time = "2025-09-21T20:02:59.431Z" }, + { url = "https://files.pythonhosted.org/packages/ef/5b/0b5799aa30380a949005a353715095d6d1da81927d6dbed5def2200a4e25/coverage-7.10.7-cp314-cp314-win32.whl", hash = "sha256:b7b8288eb7cdd268b0304632da8cb0bb93fadcfec2fe5712f7b9cc8f4d487be2", size = 221009, upload-time = "2025-09-21T20:03:01.324Z" }, + { url = "https://files.pythonhosted.org/packages/da/b0/e802fbb6eb746de006490abc9bb554b708918b6774b722bb3a0e6aa1b7de/coverage-7.10.7-cp314-cp314-win_amd64.whl", hash = "sha256:1ca6db7c8807fb9e755d0379ccc39017ce0a84dcd26d14b5a03b78563776f681", size = 221804, upload-time = "2025-09-21T20:03:03.4Z" }, + { url = "https://files.pythonhosted.org/packages/9e/e8/71d0c8e374e31f39e3389bb0bd19e527d46f00ea8571ec7ec8fd261d8b44/coverage-7.10.7-cp314-cp314-win_arm64.whl", hash = "sha256:097c1591f5af4496226d5783d036bf6fd6cd0cbc132e071b33861de756efb880", size = 220384, upload-time = "2025-09-21T20:03:05.111Z" }, + { url = "https://files.pythonhosted.org/packages/62/09/9a5608d319fa3eba7a2019addeacb8c746fb50872b57a724c9f79f146969/coverage-7.10.7-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:a62c6ef0d50e6de320c270ff91d9dd0a05e7250cac2a800b7784bae474506e63", size = 219047, upload-time = "2025-09-21T20:03:06.795Z" }, + { url = "https://files.pythonhosted.org/packages/f5/6f/f58d46f33db9f2e3647b2d0764704548c184e6f5e014bef528b7f979ef84/coverage-7.10.7-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:9fa6e4dd51fe15d8738708a973470f67a855ca50002294852e9571cdbd9433f2", size = 219266, upload-time = "2025-09-21T20:03:08.495Z" }, + { url = "https://files.pythonhosted.org/packages/74/5c/183ffc817ba68e0b443b8c934c8795553eb0c14573813415bd59941ee165/coverage-7.10.7-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:8fb190658865565c549b6b4706856d6a7b09302c797eb2cf8e7fe9dabb043f0d", size = 260767, upload-time = "2025-09-21T20:03:10.172Z" }, + { url = "https://files.pythonhosted.org/packages/0f/48/71a8abe9c1ad7e97548835e3cc1adbf361e743e9d60310c5f75c9e7bf847/coverage-7.10.7-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:affef7c76a9ef259187ef31599a9260330e0335a3011732c4b9effa01e1cd6e0", size = 262931, upload-time = "2025-09-21T20:03:11.861Z" }, + { url = "https://files.pythonhosted.org/packages/84/fd/193a8fb132acfc0a901f72020e54be5e48021e1575bb327d8ee1097a28fd/coverage-7.10.7-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6e16e07d85ca0cf8bafe5f5d23a0b850064e8e945d5677492b06bbe6f09cc699", size = 265186, upload-time = "2025-09-21T20:03:13.539Z" }, + { url = "https://files.pythonhosted.org/packages/b1/8f/74ecc30607dd95ad50e3034221113ccb1c6d4e8085cc761134782995daae/coverage-7.10.7-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:03ffc58aacdf65d2a82bbeb1ffe4d01ead4017a21bfd0454983b88ca73af94b9", size = 259470, upload-time = "2025-09-21T20:03:15.584Z" }, + { url = "https://files.pythonhosted.org/packages/0f/55/79ff53a769f20d71b07023ea115c9167c0bb56f281320520cf64c5298a96/coverage-7.10.7-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:1b4fd784344d4e52647fd7857b2af5b3fbe6c239b0b5fa63e94eb67320770e0f", size = 262626, upload-time = "2025-09-21T20:03:17.673Z" }, + { url = "https://files.pythonhosted.org/packages/88/e2/dac66c140009b61ac3fc13af673a574b00c16efdf04f9b5c740703e953c0/coverage-7.10.7-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:0ebbaddb2c19b71912c6f2518e791aa8b9f054985a0769bdb3a53ebbc765c6a1", size = 260386, upload-time = "2025-09-21T20:03:19.36Z" }, + { url = "https://files.pythonhosted.org/packages/a2/f1/f48f645e3f33bb9ca8a496bc4a9671b52f2f353146233ebd7c1df6160440/coverage-7.10.7-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:a2d9a3b260cc1d1dbdb1c582e63ddcf5363426a1a68faa0f5da28d8ee3c722a0", size = 258852, upload-time = "2025-09-21T20:03:21.007Z" }, + { url = "https://files.pythonhosted.org/packages/bb/3b/8442618972c51a7affeead957995cfa8323c0c9bcf8fa5a027421f720ff4/coverage-7.10.7-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:a3cc8638b2480865eaa3926d192e64ce6c51e3d29c849e09d5b4ad95efae5399", size = 261534, upload-time = "2025-09-21T20:03:23.12Z" }, + { url = "https://files.pythonhosted.org/packages/b2/dc/101f3fa3a45146db0cb03f5b4376e24c0aac818309da23e2de0c75295a91/coverage-7.10.7-cp314-cp314t-win32.whl", hash = "sha256:67f8c5cbcd3deb7a60b3345dffc89a961a484ed0af1f6f73de91705cc6e31235", size = 221784, upload-time = "2025-09-21T20:03:24.769Z" }, + { url = "https://files.pythonhosted.org/packages/4c/a1/74c51803fc70a8a40d7346660379e144be772bab4ac7bb6e6b905152345c/coverage-7.10.7-cp314-cp314t-win_amd64.whl", hash = "sha256:e1ed71194ef6dea7ed2d5cb5f7243d4bcd334bfb63e59878519be558078f848d", size = 222905, upload-time = "2025-09-21T20:03:26.93Z" }, + { url = "https://files.pythonhosted.org/packages/12/65/f116a6d2127df30bcafbceef0302d8a64ba87488bf6f73a6d8eebf060873/coverage-7.10.7-cp314-cp314t-win_arm64.whl", hash = "sha256:7fe650342addd8524ca63d77b2362b02345e5f1a093266787d210c70a50b471a", size = 220922, upload-time = "2025-09-21T20:03:28.672Z" }, { url = "https://files.pythonhosted.org/packages/a3/ad/d1c25053764b4c42eb294aae92ab617d2e4f803397f9c7c8295caa77a260/coverage-7.10.7-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:fff7b9c3f19957020cac546c70025331113d2e61537f6e2441bc7657913de7d3", size = 217978, upload-time = "2025-09-21T20:03:30.362Z" }, { url = "https://files.pythonhosted.org/packages/52/2f/b9f9daa39b80ece0b9548bbb723381e29bc664822d9a12c2135f8922c22b/coverage-7.10.7-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:bc91b314cef27742da486d6839b677b3f2793dfe52b51bbbb7cf736d5c29281c", size = 218370, upload-time = "2025-09-21T20:03:32.147Z" }, { url = "https://files.pythonhosted.org/packages/dd/6e/30d006c3b469e58449650642383dddf1c8fb63d44fdf92994bfd46570695/coverage-7.10.7-cp39-cp39-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:567f5c155eda8df1d3d439d40a45a6a5f029b429b06648235f1e7e51b522b396", size = 244802, upload-time = "2025-09-21T20:03:33.919Z" }, @@ -641,6 +705,32 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/9a/b8/49cf253e1e7a3bedb85199b201862dd7ca4859f75b6cf25ffa7298aa0760/coverage-7.11.0-cp313-cp313t-win32.whl", hash = "sha256:cee6291bb4fed184f1c2b663606a115c743df98a537c969c3c64b49989da96c2", size = 219180, upload-time = "2025-10-15T15:14:09.786Z" }, { url = "https://files.pythonhosted.org/packages/ac/e1/1a541703826be7ae2125a0fb7f821af5729d56bb71e946e7b933cc7a89a4/coverage-7.11.0-cp313-cp313t-win_amd64.whl", hash = "sha256:a386c1061bf98e7ea4758e4313c0ab5ecf57af341ef0f43a0bf26c2477b5c268", size = 220241, upload-time = "2025-10-15T15:14:11.471Z" }, { url = "https://files.pythonhosted.org/packages/d5/d1/5ee0e0a08621140fd418ec4020f595b4d52d7eb429ae6a0c6542b4ba6f14/coverage-7.11.0-cp313-cp313t-win_arm64.whl", hash = "sha256:f9ea02ef40bb83823b2b04964459d281688fe173e20643870bb5d2edf68bc836", size = 218510, upload-time = "2025-10-15T15:14:13.46Z" }, + { url = "https://files.pythonhosted.org/packages/f4/06/e923830c1985ce808e40a3fa3eb46c13350b3224b7da59757d37b6ce12b8/coverage-7.11.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:c770885b28fb399aaf2a65bbd1c12bf6f307ffd112d6a76c5231a94276f0c497", size = 216110, upload-time = "2025-10-15T15:14:15.157Z" }, + { url = "https://files.pythonhosted.org/packages/42/82/cdeed03bfead45203fb651ed756dfb5266028f5f939e7f06efac4041dad5/coverage-7.11.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:a3d0e2087dba64c86a6b254f43e12d264b636a39e88c5cc0a01a7c71bcfdab7e", size = 216395, upload-time = "2025-10-15T15:14:16.863Z" }, + { url = "https://files.pythonhosted.org/packages/fc/ba/e1c80caffc3199aa699813f73ff097bc2df7b31642bdbc7493600a8f1de5/coverage-7.11.0-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:73feb83bb41c32811973b8565f3705caf01d928d972b72042b44e97c71fd70d1", size = 247433, upload-time = "2025-10-15T15:14:18.589Z" }, + { url = "https://files.pythonhosted.org/packages/80/c0/5b259b029694ce0a5bbc1548834c7ba3db41d3efd3474489d7efce4ceb18/coverage-7.11.0-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:c6f31f281012235ad08f9a560976cc2fc9c95c17604ff3ab20120fe480169bca", size = 249970, upload-time = "2025-10-15T15:14:20.307Z" }, + { url = "https://files.pythonhosted.org/packages/8c/86/171b2b5e1aac7e2fd9b43f7158b987dbeb95f06d1fbecad54ad8163ae3e8/coverage-7.11.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e9570ad567f880ef675673992222746a124b9595506826b210fbe0ce3f0499cd", size = 251324, upload-time = "2025-10-15T15:14:22.419Z" }, + { url = "https://files.pythonhosted.org/packages/1a/7e/7e10414d343385b92024af3932a27a1caf75c6e27ee88ba211221ff1a145/coverage-7.11.0-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:8badf70446042553a773547a61fecaa734b55dc738cacf20c56ab04b77425e43", size = 247445, upload-time = "2025-10-15T15:14:24.205Z" }, + { url = "https://files.pythonhosted.org/packages/c4/3b/e4f966b21f5be8c4bf86ad75ae94efa0de4c99c7bbb8114476323102e345/coverage-7.11.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:a09c1211959903a479e389685b7feb8a17f59ec5a4ef9afde7650bd5eabc2777", size = 249324, upload-time = "2025-10-15T15:14:26.234Z" }, + { url = "https://files.pythonhosted.org/packages/00/a2/8479325576dfcd909244d0df215f077f47437ab852ab778cfa2f8bf4d954/coverage-7.11.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:5ef83b107f50db3f9ae40f69e34b3bd9337456c5a7fe3461c7abf8b75dd666a2", size = 247261, upload-time = "2025-10-15T15:14:28.42Z" }, + { url = "https://files.pythonhosted.org/packages/7b/d8/3a9e2db19d94d65771d0f2e21a9ea587d11b831332a73622f901157cc24b/coverage-7.11.0-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:f91f927a3215b8907e214af77200250bb6aae36eca3f760f89780d13e495388d", size = 247092, upload-time = "2025-10-15T15:14:30.784Z" }, + { url = "https://files.pythonhosted.org/packages/b3/b1/bbca3c472544f9e2ad2d5116b2379732957048be4b93a9c543fcd0207e5f/coverage-7.11.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:cdbcd376716d6b7fbfeedd687a6c4be019c5a5671b35f804ba76a4c0a778cba4", size = 248755, upload-time = "2025-10-15T15:14:32.585Z" }, + { url = "https://files.pythonhosted.org/packages/89/49/638d5a45a6a0f00af53d6b637c87007eb2297042186334e9923a61aa8854/coverage-7.11.0-cp314-cp314-win32.whl", hash = "sha256:bab7ec4bb501743edc63609320aaec8cd9188b396354f482f4de4d40a9d10721", size = 218793, upload-time = "2025-10-15T15:14:34.972Z" }, + { url = "https://files.pythonhosted.org/packages/30/cc/b675a51f2d068adb3cdf3799212c662239b0ca27f4691d1fff81b92ea850/coverage-7.11.0-cp314-cp314-win_amd64.whl", hash = "sha256:3d4ba9a449e9364a936a27322b20d32d8b166553bfe63059bd21527e681e2fad", size = 219587, upload-time = "2025-10-15T15:14:37.047Z" }, + { url = "https://files.pythonhosted.org/packages/93/98/5ac886876026de04f00820e5094fe22166b98dcb8b426bf6827aaf67048c/coverage-7.11.0-cp314-cp314-win_arm64.whl", hash = "sha256:ce37f215223af94ef0f75ac68ea096f9f8e8c8ec7d6e8c346ee45c0d363f0479", size = 218168, upload-time = "2025-10-15T15:14:38.861Z" }, + { url = "https://files.pythonhosted.org/packages/14/d1/b4145d35b3e3ecf4d917e97fc8895bcf027d854879ba401d9ff0f533f997/coverage-7.11.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:f413ce6e07e0d0dc9c433228727b619871532674b45165abafe201f200cc215f", size = 216850, upload-time = "2025-10-15T15:14:40.651Z" }, + { url = "https://files.pythonhosted.org/packages/ca/d1/7f645fc2eccd318369a8a9948acc447bb7c1ade2911e31d3c5620544c22b/coverage-7.11.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:05791e528a18f7072bf5998ba772fe29db4da1234c45c2087866b5ba4dea710e", size = 217071, upload-time = "2025-10-15T15:14:42.755Z" }, + { url = "https://files.pythonhosted.org/packages/54/7d/64d124649db2737ceced1dfcbdcb79898d5868d311730f622f8ecae84250/coverage-7.11.0-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:cacb29f420cfeb9283b803263c3b9a068924474ff19ca126ba9103e1278dfa44", size = 258570, upload-time = "2025-10-15T15:14:44.542Z" }, + { url = "https://files.pythonhosted.org/packages/6c/3f/6f5922f80dc6f2d8b2c6f974835c43f53eb4257a7797727e6ca5b7b2ec1f/coverage-7.11.0-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:314c24e700d7027ae3ab0d95fbf8d53544fca1f20345fd30cd219b737c6e58d3", size = 260738, upload-time = "2025-10-15T15:14:46.436Z" }, + { url = "https://files.pythonhosted.org/packages/0e/5f/9e883523c4647c860b3812b417a2017e361eca5b635ee658387dc11b13c1/coverage-7.11.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:630d0bd7a293ad2fc8b4b94e5758c8b2536fdf36c05f1681270203e463cbfa9b", size = 262994, upload-time = "2025-10-15T15:14:48.3Z" }, + { url = "https://files.pythonhosted.org/packages/07/bb/43b5a8e94c09c8bf51743ffc65c4c841a4ca5d3ed191d0a6919c379a1b83/coverage-7.11.0-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e89641f5175d65e2dbb44db15fe4ea48fade5d5bbb9868fdc2b4fce22f4a469d", size = 257282, upload-time = "2025-10-15T15:14:50.236Z" }, + { url = "https://files.pythonhosted.org/packages/aa/e5/0ead8af411411330b928733e1d201384b39251a5f043c1612970310e8283/coverage-7.11.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:c9f08ea03114a637dab06cedb2e914da9dc67fa52c6015c018ff43fdde25b9c2", size = 260430, upload-time = "2025-10-15T15:14:52.413Z" }, + { url = "https://files.pythonhosted.org/packages/ae/66/03dd8bb0ba5b971620dcaac145461950f6d8204953e535d2b20c6b65d729/coverage-7.11.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:ce9f3bde4e9b031eaf1eb61df95c1401427029ea1bfddb8621c1161dcb0fa02e", size = 258190, upload-time = "2025-10-15T15:14:54.268Z" }, + { url = "https://files.pythonhosted.org/packages/45/ae/28a9cce40bf3174426cb2f7e71ee172d98e7f6446dff936a7ccecee34b14/coverage-7.11.0-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:e4dc07e95495923d6fd4d6c27bf70769425b71c89053083843fd78f378558996", size = 256658, upload-time = "2025-10-15T15:14:56.436Z" }, + { url = "https://files.pythonhosted.org/packages/5c/7c/3a44234a8599513684bfc8684878fd7b126c2760f79712bb78c56f19efc4/coverage-7.11.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:424538266794db2861db4922b05d729ade0940ee69dcf0591ce8f69784db0e11", size = 259342, upload-time = "2025-10-15T15:14:58.538Z" }, + { url = "https://files.pythonhosted.org/packages/e1/e6/0108519cba871af0351725ebdb8660fd7a0fe2ba3850d56d32490c7d9b4b/coverage-7.11.0-cp314-cp314t-win32.whl", hash = "sha256:4c1eeb3fb8eb9e0190bebafd0462936f75717687117339f708f395fe455acc73", size = 219568, upload-time = "2025-10-15T15:15:00.382Z" }, + { url = "https://files.pythonhosted.org/packages/c9/76/44ba876e0942b4e62fdde23ccb029ddb16d19ba1bef081edd00857ba0b16/coverage-7.11.0-cp314-cp314t-win_amd64.whl", hash = "sha256:b56efee146c98dbf2cf5cffc61b9829d1e94442df4d7398b26892a53992d3547", size = 220687, upload-time = "2025-10-15T15:15:02.322Z" }, + { url = "https://files.pythonhosted.org/packages/b9/0c/0df55ecb20d0d0ed5c322e10a441775e1a3a5d78c60f0c4e1abfe6fcf949/coverage-7.11.0-cp314-cp314t-win_arm64.whl", hash = "sha256:b5c2705afa83f49bd91962a4094b6b082f94aef7626365ab3f8f4bd159c5acf3", size = 218711, upload-time = "2025-10-15T15:15:04.575Z" }, { url = "https://files.pythonhosted.org/packages/5f/04/642c1d8a448ae5ea1369eac8495740a79eb4e581a9fb0cbdce56bbf56da1/coverage-7.11.0-py3-none-any.whl", hash = "sha256:4b7589765348d78fb4e5fb6ea35d07564e387da2fc5efff62e0222971f155f68", size = 207761, upload-time = "2025-10-15T15:15:06.439Z" }, ] @@ -676,6 +766,21 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/96/92/8a6a9525893325fc057a01f654d7efc2c64b9de90413adcf605a85744ff4/cryptography-46.0.3-cp311-abi3-win32.whl", hash = "sha256:f260d0d41e9b4da1ed1e0f1ce571f97fe370b152ab18778e9e8f67d6af432018", size = 3055988, upload-time = "2025-10-15T23:17:14.65Z" }, { url = "https://files.pythonhosted.org/packages/7e/bf/80fbf45253ea585a1e492a6a17efcb93467701fa79e71550a430c5e60df0/cryptography-46.0.3-cp311-abi3-win_amd64.whl", hash = "sha256:a9a3008438615669153eb86b26b61e09993921ebdd75385ddd748702c5adfddb", size = 3514451, upload-time = "2025-10-15T23:17:16.142Z" }, { url = "https://files.pythonhosted.org/packages/2e/af/9b302da4c87b0beb9db4e756386a7c6c5b8003cd0e742277888d352ae91d/cryptography-46.0.3-cp311-abi3-win_arm64.whl", hash = "sha256:5d7f93296ee28f68447397bf5198428c9aeeab45705a55d53a6343455dcb2c3c", size = 2928007, upload-time = "2025-10-15T23:17:18.04Z" }, + { url = "https://files.pythonhosted.org/packages/f5/e2/a510aa736755bffa9d2f75029c229111a1d02f8ecd5de03078f4c18d91a3/cryptography-46.0.3-cp314-cp314t-macosx_10_9_universal2.whl", hash = "sha256:00a5e7e87938e5ff9ff5447ab086a5706a957137e6e433841e9d24f38a065217", size = 7158012, upload-time = "2025-10-15T23:17:19.982Z" }, + { url = "https://files.pythonhosted.org/packages/73/dc/9aa866fbdbb95b02e7f9d086f1fccfeebf8953509b87e3f28fff927ff8a0/cryptography-46.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:c8daeb2d2174beb4575b77482320303f3d39b8e81153da4f0fb08eb5fe86a6c5", size = 4288728, upload-time = "2025-10-15T23:17:21.527Z" }, + { url = "https://files.pythonhosted.org/packages/c5/fd/bc1daf8230eaa075184cbbf5f8cd00ba9db4fd32d63fb83da4671b72ed8a/cryptography-46.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:39b6755623145ad5eff1dab323f4eae2a32a77a7abef2c5089a04a3d04366715", size = 4435078, upload-time = "2025-10-15T23:17:23.042Z" }, + { url = "https://files.pythonhosted.org/packages/82/98/d3bd5407ce4c60017f8ff9e63ffee4200ab3e23fe05b765cab805a7db008/cryptography-46.0.3-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:db391fa7c66df6762ee3f00c95a89e6d428f4d60e7abc8328f4fe155b5ac6e54", size = 4293460, upload-time = "2025-10-15T23:17:24.885Z" }, + { url = "https://files.pythonhosted.org/packages/26/e9/e23e7900983c2b8af7a08098db406cf989d7f09caea7897e347598d4cd5b/cryptography-46.0.3-cp314-cp314t-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:78a97cf6a8839a48c49271cdcbd5cf37ca2c1d6b7fdd86cc864f302b5e9bf459", size = 3995237, upload-time = "2025-10-15T23:17:26.449Z" }, + { url = "https://files.pythonhosted.org/packages/91/15/af68c509d4a138cfe299d0d7ddb14afba15233223ebd933b4bbdbc7155d3/cryptography-46.0.3-cp314-cp314t-manylinux_2_28_ppc64le.whl", hash = "sha256:dfb781ff7eaa91a6f7fd41776ec37c5853c795d3b358d4896fdbb5df168af422", size = 4967344, upload-time = "2025-10-15T23:17:28.06Z" }, + { url = "https://files.pythonhosted.org/packages/ca/e3/8643d077c53868b681af077edf6b3cb58288b5423610f21c62aadcbe99f4/cryptography-46.0.3-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:6f61efb26e76c45c4a227835ddeae96d83624fb0d29eb5df5b96e14ed1a0afb7", size = 4466564, upload-time = "2025-10-15T23:17:29.665Z" }, + { url = "https://files.pythonhosted.org/packages/0e/43/c1e8726fa59c236ff477ff2b5dc071e54b21e5a1e51aa2cee1676f1c986f/cryptography-46.0.3-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:23b1a8f26e43f47ceb6d6a43115f33a5a37d57df4ea0ca295b780ae8546e8044", size = 4292415, upload-time = "2025-10-15T23:17:31.686Z" }, + { url = "https://files.pythonhosted.org/packages/42/f9/2f8fefdb1aee8a8e3256a0568cffc4e6d517b256a2fe97a029b3f1b9fe7e/cryptography-46.0.3-cp314-cp314t-manylinux_2_34_ppc64le.whl", hash = "sha256:b419ae593c86b87014b9be7396b385491ad7f320bde96826d0dd174459e54665", size = 4931457, upload-time = "2025-10-15T23:17:33.478Z" }, + { url = "https://files.pythonhosted.org/packages/79/30/9b54127a9a778ccd6d27c3da7563e9f2d341826075ceab89ae3b41bf5be2/cryptography-46.0.3-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:50fc3343ac490c6b08c0cf0d704e881d0d660be923fd3076db3e932007e726e3", size = 4466074, upload-time = "2025-10-15T23:17:35.158Z" }, + { url = "https://files.pythonhosted.org/packages/ac/68/b4f4a10928e26c941b1b6a179143af9f4d27d88fe84a6a3c53592d2e76bf/cryptography-46.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:22d7e97932f511d6b0b04f2bfd818d73dcd5928db509460aaf48384778eb6d20", size = 4420569, upload-time = "2025-10-15T23:17:37.188Z" }, + { url = "https://files.pythonhosted.org/packages/a3/49/3746dab4c0d1979888f125226357d3262a6dd40e114ac29e3d2abdf1ec55/cryptography-46.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:d55f3dffadd674514ad19451161118fd010988540cee43d8bc20675e775925de", size = 4681941, upload-time = "2025-10-15T23:17:39.236Z" }, + { url = "https://files.pythonhosted.org/packages/fd/30/27654c1dbaf7e4a3531fa1fc77986d04aefa4d6d78259a62c9dc13d7ad36/cryptography-46.0.3-cp314-cp314t-win32.whl", hash = "sha256:8a6e050cb6164d3f830453754094c086ff2d0b2f3a897a1d9820f6139a1f0914", size = 3022339, upload-time = "2025-10-15T23:17:40.888Z" }, + { url = "https://files.pythonhosted.org/packages/f6/30/640f34ccd4d2a1bc88367b54b926b781b5a018d65f404d409aba76a84b1c/cryptography-46.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:760f83faa07f8b64e9c33fc963d790a2edb24efb479e3520c14a45741cd9b2db", size = 3494315, upload-time = "2025-10-15T23:17:42.769Z" }, + { url = "https://files.pythonhosted.org/packages/ba/8b/88cc7e3bd0a8e7b861f26981f7b820e1f46aa9d26cc482d0feba0ecb4919/cryptography-46.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:516ea134e703e9fe26bcd1277a4b59ad30586ea90c365a87781d7887a646fe21", size = 2919331, upload-time = "2025-10-15T23:17:44.468Z" }, { url = "https://files.pythonhosted.org/packages/fd/23/45fe7f376a7df8daf6da3556603b36f53475a99ce4faacb6ba2cf3d82021/cryptography-46.0.3-cp38-abi3-macosx_10_9_universal2.whl", hash = "sha256:cb3d760a6117f621261d662bccc8ef5bc32ca673e037c83fbe565324f5c46936", size = 7218248, upload-time = "2025-10-15T23:17:46.294Z" }, { url = "https://files.pythonhosted.org/packages/27/32/b68d27471372737054cbd34c84981f9edbc24fe67ca225d389799614e27f/cryptography-46.0.3-cp38-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:4b7387121ac7d15e550f5cb4a43aef2559ed759c35df7336c402bb8275ac9683", size = 4294089, upload-time = "2025-10-15T23:17:48.269Z" }, { url = "https://files.pythonhosted.org/packages/26/42/fa8389d4478368743e24e61eea78846a0006caffaf72ea24a15159215a14/cryptography-46.0.3-cp38-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:15ab9b093e8f09daab0f2159bb7e47532596075139dd74365da52ecc9cb46c5d", size = 4440029, upload-time = "2025-10-15T23:17:49.837Z" }, @@ -764,7 +869,7 @@ requires-dist = [ { name = "django", specifier = ">=4.2" }, { name = "jwcrypto", specifier = ">=1.5.0" }, { name = "m2r", marker = "extra == 'dev'" }, - { name = "oauthlib", specifier = ">=3.2.2" }, + { name = "oauthlib", specifier = ">=3.3.0" }, { name = "pytest", marker = "extra == 'dev'" }, { name = "pytest-cov", marker = "extra == 'dev'" }, { name = "requests", specifier = ">=2.13.0" }, @@ -833,7 +938,7 @@ dependencies = [ [package.metadata] requires-dist = [ - { name = "django", specifier = ">=4.2,<=5.1" }, + { name = "django", specifier = ">=4.2,<=5.2" }, { name = "django-cors-headers", specifier = "==3.14.0" }, { name = "django-environ", specifier = "==0.11.2" }, { name = "django-oauth-toolkit", editable = "." }, @@ -1069,6 +1174,28 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/80/d6/2d1b89f6ca4bff1036499b1e29a1d02d282259f3681540e16563f27ebc23/markupsafe-3.0.3-cp313-cp313t-win32.whl", hash = "sha256:69c0b73548bc525c8cb9a251cddf1931d1db4d2258e9599c28c07ef3580ef354", size = 14612, upload-time = "2025-09-27T18:37:02.639Z" }, { url = "https://files.pythonhosted.org/packages/2b/98/e48a4bfba0a0ffcf9925fe2d69240bfaa19c6f7507b8cd09c70684a53c1e/markupsafe-3.0.3-cp313-cp313t-win_amd64.whl", hash = "sha256:1b4b79e8ebf6b55351f0d91fe80f893b4743f104bff22e90697db1590e47a218", size = 15200, upload-time = "2025-09-27T18:37:03.582Z" }, { url = "https://files.pythonhosted.org/packages/0e/72/e3cc540f351f316e9ed0f092757459afbc595824ca724cbc5a5d4263713f/markupsafe-3.0.3-cp313-cp313t-win_arm64.whl", hash = "sha256:ad2cf8aa28b8c020ab2fc8287b0f823d0a7d8630784c31e9ee5edea20f406287", size = 13973, upload-time = "2025-09-27T18:37:04.929Z" }, + { url = "https://files.pythonhosted.org/packages/33/8a/8e42d4838cd89b7dde187011e97fe6c3af66d8c044997d2183fbd6d31352/markupsafe-3.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:eaa9599de571d72e2daf60164784109f19978b327a3910d3e9de8c97b5b70cfe", size = 11619, upload-time = "2025-09-27T18:37:06.342Z" }, + { url = "https://files.pythonhosted.org/packages/b5/64/7660f8a4a8e53c924d0fa05dc3a55c9cee10bbd82b11c5afb27d44b096ce/markupsafe-3.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c47a551199eb8eb2121d4f0f15ae0f923d31350ab9280078d1e5f12b249e0026", size = 12029, upload-time = "2025-09-27T18:37:07.213Z" }, + { url = "https://files.pythonhosted.org/packages/da/ef/e648bfd021127bef5fa12e1720ffed0c6cbb8310c8d9bea7266337ff06de/markupsafe-3.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f34c41761022dd093b4b6896d4810782ffbabe30f2d443ff5f083e0cbbb8c737", size = 24408, upload-time = "2025-09-27T18:37:09.572Z" }, + { url = "https://files.pythonhosted.org/packages/41/3c/a36c2450754618e62008bf7435ccb0f88053e07592e6028a34776213d877/markupsafe-3.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:457a69a9577064c05a97c41f4e65148652db078a3a509039e64d3467b9e7ef97", size = 23005, upload-time = "2025-09-27T18:37:10.58Z" }, + { url = "https://files.pythonhosted.org/packages/bc/20/b7fdf89a8456b099837cd1dc21974632a02a999ec9bf7ca3e490aacd98e7/markupsafe-3.0.3-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e8afc3f2ccfa24215f8cb28dcf43f0113ac3c37c2f0f0806d8c70e4228c5cf4d", size = 22048, upload-time = "2025-09-27T18:37:11.547Z" }, + { url = "https://files.pythonhosted.org/packages/9a/a7/591f592afdc734f47db08a75793a55d7fbcc6902a723ae4cfbab61010cc5/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:ec15a59cf5af7be74194f7ab02d0f59a62bdcf1a537677ce67a2537c9b87fcda", size = 23821, upload-time = "2025-09-27T18:37:12.48Z" }, + { url = "https://files.pythonhosted.org/packages/7d/33/45b24e4f44195b26521bc6f1a82197118f74df348556594bd2262bda1038/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:0eb9ff8191e8498cca014656ae6b8d61f39da5f95b488805da4bb029cccbfbaf", size = 21606, upload-time = "2025-09-27T18:37:13.485Z" }, + { url = "https://files.pythonhosted.org/packages/ff/0e/53dfaca23a69fbfbbf17a4b64072090e70717344c52eaaaa9c5ddff1e5f0/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:2713baf880df847f2bece4230d4d094280f4e67b1e813eec43b4c0e144a34ffe", size = 23043, upload-time = "2025-09-27T18:37:14.408Z" }, + { url = "https://files.pythonhosted.org/packages/46/11/f333a06fc16236d5238bfe74daccbca41459dcd8d1fa952e8fbd5dccfb70/markupsafe-3.0.3-cp314-cp314-win32.whl", hash = "sha256:729586769a26dbceff69f7a7dbbf59ab6572b99d94576a5592625d5b411576b9", size = 14747, upload-time = "2025-09-27T18:37:15.36Z" }, + { url = "https://files.pythonhosted.org/packages/28/52/182836104b33b444e400b14f797212f720cbc9ed6ba34c800639d154e821/markupsafe-3.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:bdc919ead48f234740ad807933cdf545180bfbe9342c2bb451556db2ed958581", size = 15341, upload-time = "2025-09-27T18:37:16.496Z" }, + { url = "https://files.pythonhosted.org/packages/6f/18/acf23e91bd94fd7b3031558b1f013adfa21a8e407a3fdb32745538730382/markupsafe-3.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:5a7d5dc5140555cf21a6fefbdbf8723f06fcd2f63ef108f2854de715e4422cb4", size = 14073, upload-time = "2025-09-27T18:37:17.476Z" }, + { url = "https://files.pythonhosted.org/packages/3c/f0/57689aa4076e1b43b15fdfa646b04653969d50cf30c32a102762be2485da/markupsafe-3.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:1353ef0c1b138e1907ae78e2f6c63ff67501122006b0f9abad68fda5f4ffc6ab", size = 11661, upload-time = "2025-09-27T18:37:18.453Z" }, + { url = "https://files.pythonhosted.org/packages/89/c3/2e67a7ca217c6912985ec766c6393b636fb0c2344443ff9d91404dc4c79f/markupsafe-3.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:1085e7fbddd3be5f89cc898938f42c0b3c711fdcb37d75221de2666af647c175", size = 12069, upload-time = "2025-09-27T18:37:19.332Z" }, + { url = "https://files.pythonhosted.org/packages/f0/00/be561dce4e6ca66b15276e184ce4b8aec61fe83662cce2f7d72bd3249d28/markupsafe-3.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1b52b4fb9df4eb9ae465f8d0c228a00624de2334f216f178a995ccdcf82c4634", size = 25670, upload-time = "2025-09-27T18:37:20.245Z" }, + { url = "https://files.pythonhosted.org/packages/50/09/c419f6f5a92e5fadde27efd190eca90f05e1261b10dbd8cbcb39cd8ea1dc/markupsafe-3.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fed51ac40f757d41b7c48425901843666a6677e3e8eb0abcff09e4ba6e664f50", size = 23598, upload-time = "2025-09-27T18:37:21.177Z" }, + { url = "https://files.pythonhosted.org/packages/22/44/a0681611106e0b2921b3033fc19bc53323e0b50bc70cffdd19f7d679bb66/markupsafe-3.0.3-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f190daf01f13c72eac4efd5c430a8de82489d9cff23c364c3ea822545032993e", size = 23261, upload-time = "2025-09-27T18:37:22.167Z" }, + { url = "https://files.pythonhosted.org/packages/5f/57/1b0b3f100259dc9fffe780cfb60d4be71375510e435efec3d116b6436d43/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e56b7d45a839a697b5eb268c82a71bd8c7f6c94d6fd50c3d577fa39a9f1409f5", size = 24835, upload-time = "2025-09-27T18:37:23.296Z" }, + { url = "https://files.pythonhosted.org/packages/26/6a/4bf6d0c97c4920f1597cc14dd720705eca0bf7c787aebc6bb4d1bead5388/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:f3e98bb3798ead92273dc0e5fd0f31ade220f59a266ffd8a4f6065e0a3ce0523", size = 22733, upload-time = "2025-09-27T18:37:24.237Z" }, + { url = "https://files.pythonhosted.org/packages/14/c7/ca723101509b518797fedc2fdf79ba57f886b4aca8a7d31857ba3ee8281f/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:5678211cb9333a6468fb8d8be0305520aa073f50d17f089b5b4b477ea6e67fdc", size = 23672, upload-time = "2025-09-27T18:37:25.271Z" }, + { url = "https://files.pythonhosted.org/packages/fb/df/5bd7a48c256faecd1d36edc13133e51397e41b73bb77e1a69deab746ebac/markupsafe-3.0.3-cp314-cp314t-win32.whl", hash = "sha256:915c04ba3851909ce68ccc2b8e2cd691618c4dc4c4232fb7982bca3f41fd8c3d", size = 14819, upload-time = "2025-09-27T18:37:26.285Z" }, + { url = "https://files.pythonhosted.org/packages/1a/8a/0402ba61a2f16038b48b39bccca271134be00c5c9f0f623208399333c448/markupsafe-3.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4faffd047e07c38848ce017e8725090413cd80cbc23d86e55c587bf979e579c9", size = 15426, upload-time = "2025-09-27T18:37:27.316Z" }, + { url = "https://files.pythonhosted.org/packages/70/bc/6f1c2f612465f5fa89b95bead1f44dcb607670fd42891d8fdcd5d039f4f4/markupsafe-3.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:32001d6a8fc98c8cb5c947787c5d08b0a50663d139f1305bac5885d98d9b40fa", size = 14146, upload-time = "2025-09-27T18:37:28.327Z" }, { url = "https://files.pythonhosted.org/packages/56/23/0d8c13a44bde9154821586520840643467aee574d8ce79a17da539ee7fed/markupsafe-3.0.3-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:15d939a21d546304880945ca1ecb8a039db6b4dc49b2c5a400387cdae6a62e26", size = 11623, upload-time = "2025-09-27T18:37:29.296Z" }, { url = "https://files.pythonhosted.org/packages/fd/23/07a2cb9a8045d5f3f0890a8c3bc0859d7a47bfd9a560b563899bec7b72ed/markupsafe-3.0.3-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:f71a396b3bf33ecaa1626c255855702aca4d3d9fea5e051b41ac59a9c1c41edc", size = 12049, upload-time = "2025-09-27T18:37:30.234Z" }, { url = "https://files.pythonhosted.org/packages/bc/e4/6be85eb81503f8e11b61c0b6369b6e077dcf0a74adbd9ebf6b349937b4e9/markupsafe-3.0.3-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0f4b68347f8c5eab4a13419215bdfd7f8c9b19f2b25520968adfad23eb0ce60c", size = 21923, upload-time = "2025-09-27T18:37:31.177Z" }, @@ -1634,6 +1761,22 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/55/92/afed3d497f7c186dc71e6ee6d4fcb0acfa5f7d0a1a2878f8beae379ae0cc/tomli-2.3.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:ad805ea85eda330dbad64c7ea7a4556259665bdf9d2672f5dccc740eb9d3ca05", size = 248909, upload-time = "2025-10-08T22:01:23.859Z" }, { url = "https://files.pythonhosted.org/packages/f8/84/ef50c51b5a9472e7265ce1ffc7f24cd4023d289e109f669bdb1553f6a7c2/tomli-2.3.0-cp313-cp313-win32.whl", hash = "sha256:97d5eec30149fd3294270e889b4234023f2c69747e555a27bd708828353ab606", size = 96946, upload-time = "2025-10-08T22:01:24.893Z" }, { url = "https://files.pythonhosted.org/packages/b2/b7/718cd1da0884f281f95ccfa3a6cc572d30053cba64603f79d431d3c9b61b/tomli-2.3.0-cp313-cp313-win_amd64.whl", hash = "sha256:0c95ca56fbe89e065c6ead5b593ee64b84a26fca063b5d71a1122bf26e533999", size = 107705, upload-time = "2025-10-08T22:01:26.153Z" }, + { url = "https://files.pythonhosted.org/packages/19/94/aeafa14a52e16163008060506fcb6aa1949d13548d13752171a755c65611/tomli-2.3.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:cebc6fe843e0733ee827a282aca4999b596241195f43b4cc371d64fc6639da9e", size = 154244, upload-time = "2025-10-08T22:01:27.06Z" }, + { url = "https://files.pythonhosted.org/packages/db/e4/1e58409aa78eefa47ccd19779fc6f36787edbe7d4cd330eeeedb33a4515b/tomli-2.3.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:4c2ef0244c75aba9355561272009d934953817c49f47d768070c3c94355c2aa3", size = 148637, upload-time = "2025-10-08T22:01:28.059Z" }, + { url = "https://files.pythonhosted.org/packages/26/b6/d1eccb62f665e44359226811064596dd6a366ea1f985839c566cd61525ae/tomli-2.3.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c22a8bf253bacc0cf11f35ad9808b6cb75ada2631c2d97c971122583b129afbc", size = 241925, upload-time = "2025-10-08T22:01:29.066Z" }, + { url = "https://files.pythonhosted.org/packages/70/91/7cdab9a03e6d3d2bb11beae108da5bdc1c34bdeb06e21163482544ddcc90/tomli-2.3.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0eea8cc5c5e9f89c9b90c4896a8deefc74f518db5927d0e0e8d4a80953d774d0", size = 249045, upload-time = "2025-10-08T22:01:31.98Z" }, + { url = "https://files.pythonhosted.org/packages/15/1b/8c26874ed1f6e4f1fcfeb868db8a794cbe9f227299402db58cfcc858766c/tomli-2.3.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:b74a0e59ec5d15127acdabd75ea17726ac4c5178ae51b85bfe39c4f8a278e879", size = 245835, upload-time = "2025-10-08T22:01:32.989Z" }, + { url = "https://files.pythonhosted.org/packages/fd/42/8e3c6a9a4b1a1360c1a2a39f0b972cef2cc9ebd56025168c4137192a9321/tomli-2.3.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:b5870b50c9db823c595983571d1296a6ff3e1b88f734a4c8f6fc6188397de005", size = 253109, upload-time = "2025-10-08T22:01:34.052Z" }, + { url = "https://files.pythonhosted.org/packages/22/0c/b4da635000a71b5f80130937eeac12e686eefb376b8dee113b4a582bba42/tomli-2.3.0-cp314-cp314-win32.whl", hash = "sha256:feb0dacc61170ed7ab602d3d972a58f14ee3ee60494292d384649a3dc38ef463", size = 97930, upload-time = "2025-10-08T22:01:35.082Z" }, + { url = "https://files.pythonhosted.org/packages/b9/74/cb1abc870a418ae99cd5c9547d6bce30701a954e0e721821df483ef7223c/tomli-2.3.0-cp314-cp314-win_amd64.whl", hash = "sha256:b273fcbd7fc64dc3600c098e39136522650c49bca95df2d11cf3b626422392c8", size = 107964, upload-time = "2025-10-08T22:01:36.057Z" }, + { url = "https://files.pythonhosted.org/packages/54/78/5c46fff6432a712af9f792944f4fcd7067d8823157949f4e40c56b8b3c83/tomli-2.3.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:940d56ee0410fa17ee1f12b817b37a4d4e4dc4d27340863cc67236c74f582e77", size = 163065, upload-time = "2025-10-08T22:01:37.27Z" }, + { url = "https://files.pythonhosted.org/packages/39/67/f85d9bd23182f45eca8939cd2bc7050e1f90c41f4a2ecbbd5963a1d1c486/tomli-2.3.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:f85209946d1fe94416debbb88d00eb92ce9cd5266775424ff81bc959e001acaf", size = 159088, upload-time = "2025-10-08T22:01:38.235Z" }, + { url = "https://files.pythonhosted.org/packages/26/5a/4b546a0405b9cc0659b399f12b6adb750757baf04250b148d3c5059fc4eb/tomli-2.3.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a56212bdcce682e56b0aaf79e869ba5d15a6163f88d5451cbde388d48b13f530", size = 268193, upload-time = "2025-10-08T22:01:39.712Z" }, + { url = "https://files.pythonhosted.org/packages/42/4f/2c12a72ae22cf7b59a7fe75b3465b7aba40ea9145d026ba41cb382075b0e/tomli-2.3.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c5f3ffd1e098dfc032d4d3af5c0ac64f6d286d98bc148698356847b80fa4de1b", size = 275488, upload-time = "2025-10-08T22:01:40.773Z" }, + { url = "https://files.pythonhosted.org/packages/92/04/a038d65dbe160c3aa5a624e93ad98111090f6804027d474ba9c37c8ae186/tomli-2.3.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:5e01decd096b1530d97d5d85cb4dff4af2d8347bd35686654a004f8dea20fc67", size = 272669, upload-time = "2025-10-08T22:01:41.824Z" }, + { url = "https://files.pythonhosted.org/packages/be/2f/8b7c60a9d1612a7cbc39ffcca4f21a73bf368a80fc25bccf8253e2563267/tomli-2.3.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:8a35dd0e643bb2610f156cca8db95d213a90015c11fee76c946aa62b7ae7e02f", size = 279709, upload-time = "2025-10-08T22:01:43.177Z" }, + { url = "https://files.pythonhosted.org/packages/7e/46/cc36c679f09f27ded940281c38607716c86cf8ba4a518d524e349c8b4874/tomli-2.3.0-cp314-cp314t-win32.whl", hash = "sha256:a1f7f282fe248311650081faafa5f4732bdbfef5d45fe3f2e702fbc6f2d496e0", size = 107563, upload-time = "2025-10-08T22:01:44.233Z" }, + { url = "https://files.pythonhosted.org/packages/84/ff/426ca8683cf7b753614480484f6437f568fd2fda2edbdf57a2d3d8b27a0b/tomli-2.3.0-cp314-cp314t-win_amd64.whl", hash = "sha256:70a251f8d4ba2d9ac2542eecf008b3c8a9fc5c3f9f02c56a9d7952612be2fdba", size = 119756, upload-time = "2025-10-08T22:01:45.234Z" }, { url = "https://files.pythonhosted.org/packages/77/b8/0135fadc89e73be292b473cb820b4f5a08197779206b33191e801feeae40/tomli-2.3.0-py3-none-any.whl", hash = "sha256:e95b1af3c5b07d9e643909b5abbec77cd9f1217e6d0bca72b0234736b9fb1f1b", size = 14408, upload-time = "2025-10-08T22:01:46.04Z" }, ]