From af89cf8f614304cdbbe8934906522ef81b5def1a Mon Sep 17 00:00:00 2001 From: Snehil Kishore Date: Tue, 29 Jul 2025 00:05:35 +0530 Subject: [PATCH 01/23] feat: Add DPoP authentication support --- packages/auth0_api_python/README.md | 72 ++ .../src/auth0_api_python/api_client.py | 459 ++++++- .../src/auth0_api_python/config.py | 14 +- .../src/auth0_api_python/errors.py | 100 +- .../src/auth0_api_python/token_utils.py | 136 ++- .../src/auth0_api_python/utils.py | 72 +- .../auth0_api_python/tests/test_api_client.py | 1051 ++++++++++++++++- 7 files changed, 1865 insertions(+), 39 deletions(-) diff --git a/packages/auth0_api_python/README.md b/packages/auth0_api_python/README.md index 8f5b67e..a69c631 100644 --- a/packages/auth0_api_python/README.md +++ b/packages/auth0_api_python/README.md @@ -6,6 +6,24 @@ It’s intended as a foundation for building more framework-specific integration 📚 [Documentation](#documentation) - 🚀 [Getting Started](#getting-started) - 💬 [Feedback](#feedback) +## Features & Authentication Schemes + +This SDK provides comprehensive support for securing APIs with Auth0-issued access tokens: + +### **Authentication Schemes** +- **Bearer Token Authentication** - Traditional OAuth 2.0 Bearer tokens (RS256) +- **DPoP Authentication** - Enhanced security with Demonstrating Proof-of-Possession (ES256) +- **Mixed Mode Support** - Seamlessly handle both Bearer and DPoP in the same API + +### **Core Features** +- **Unified Entry Point**: `verify_request()` - automatically detects and validates Bearer or DPoP schemes +- **OIDC Discovery** - Automatic fetching of Auth0 metadata and JWKS +- **JWT Validation** - Complete RS256 signature verification with claim validation +- **DPoP Proof Verification** - Full RFC 9449 compliance with ES256 signature validation +- **Flexible Configuration** - Support for both "Allowed" and "Required" DPoP modes +- **Comprehensive Error Handling** - Detailed errors with proper HTTP status codes and WWW-Authenticate headers +- **Framework Agnostic** - Works with FastAPI, Django, Flask, or any Python web framework + ## Documentation - [Docs Site](https://auth0.com/docs) - explore our docs site and learn more about Auth0. @@ -80,6 +98,60 @@ decoded_and_verified_token = await api_client.verify_access_token( If the token lacks `my_custom_claim` or fails any standard check (issuer mismatch, expired token, invalid signature), the method raises a `VerifyAccessTokenError`. +### 4. DPoP Authentication + +This library supports **DPoP (Demonstrating Proof-of-Possession)** for enhanced security, allowing clients to prove possession of private keys bound to access tokens. + +#### Allowed Mode (Default) + +Accepts both Bearer and DPoP tokens - ideal for gradual migration: + +```python +api_client = ApiClient(ApiClientOptions( + domain="", + audience="", + dpop_enabled=True, # Default - enables DPoP support + dpop_required=False # Default - allows both Bearer and DPoP +)) + +# Use verify_request() for automatic scheme detection +result = await api_client.verify_request( + headers={ + "authorization": "DPoP eyJ0eXAiOiJKV1Q...", # DPoP scheme + "dpop": "eyJ0eXAiOiJkcG9wK2p3dC...", # DPoP proof + }, + http_method="GET", + http_url="https://api.example.com/resource" +) +``` + +#### Required Mode + +Enforces DPoP-only authentication, rejecting Bearer tokens: + +```python +api_client = ApiClient(ApiClientOptions( + domain="", + audience="", + dpop_required=True # Rejects Bearer tokens +)) +``` + +#### Configuration Options + +```python +api_client = ApiClient(ApiClientOptions( + domain="", + audience="", + dpop_enabled=True, # Enable/disable DPoP support + dpop_required=False, # Require DPoP (reject Bearer) + dpop_iat_leeway=30, # Clock skew tolerance (seconds) + dpop_iat_offset=300, # Maximum proof age (seconds) +)) +``` + +📖 **[Complete DPoP Documentation](docs/DPOP.md)** - Detailed guide with examples, error handling, and security considerations. + ## Feedback ### Contributing diff --git a/packages/auth0_api_python/src/auth0_api_python/api_client.py b/packages/auth0_api_python/src/auth0_api_python/api_client.py index b38409e..9123060 100644 --- a/packages/auth0_api_python/src/auth0_api_python/api_client.py +++ b/packages/auth0_api_python/src/auth0_api_python/api_client.py @@ -1,11 +1,20 @@ import time -from typing import Optional, List, Dict, Any +import hashlib +from typing import Optional, List, Dict, Any, Tuple from authlib.jose import JsonWebToken, JsonWebKey from .config import ApiClientOptions -from .errors import MissingRequiredArgumentError, VerifyAccessTokenError -from .utils import fetch_oidc_metadata, fetch_jwks, get_unverified_header +from .errors import ( + MissingRequiredArgumentError, + VerifyAccessTokenError, + InvalidAuthSchemeError, + InvalidDpopProofError, + MissingAuthorizationError, + BaseAuthError +) +from .utils import fetch_oidc_metadata, fetch_jwks, get_unverified_header, normalize_url_for_htu, sha256_base64url, calculate_jwk_thumbprint + class ApiClient: """ @@ -14,7 +23,6 @@ class ApiClient: """ def __init__(self, options: ApiClientOptions): - if not options.domain: raise MissingRequiredArgumentError("domain") if not options.audience: @@ -26,25 +34,185 @@ def __init__(self, options: ApiClientOptions): self._jwt = JsonWebToken(["RS256"]) - async def _discover(self) -> Dict[str, Any]: - """Lazy-load OIDC discovery metadata.""" - if self._metadata is None: - self._metadata = await fetch_oidc_metadata( - domain=self.options.domain, - custom_fetch=self.options.custom_fetch - ) - return self._metadata + self._dpop_algorithms = ["ES256"] + self._dpop_jwt = JsonWebToken(self._dpop_algorithms) - async def _load_jwks(self) -> Dict[str, Any]: - """Fetches and caches JWKS data from the OIDC metadata.""" - if self._jwks_data is None: - metadata = await self._discover() - jwks_uri = metadata["jwks_uri"] - self._jwks_data = await fetch_jwks( - jwks_uri=jwks_uri, - custom_fetch=self.options.custom_fetch + + async def verify_request( + self, + headers: Dict[str, str], + http_method: Optional[str] = None, + http_url: Optional[str] = None + ) -> Dict[str, Any]: + """ + Dispatch based on Authorization scheme: + • If scheme is 'DPoP', verifies both access token and DPoP proof + • If scheme is 'Bearer', verifies only the access token + + Args: + headers: HTTP headers dict containing: + - "authorization": The Authorization header value (required) + - "dpop": The DPoP proof header value (required for DPoP) + http_method: The HTTP method (required for DPoP) + http_url: The HTTP URL (required for DPoP) + + Returns: + The decoded access token claims + + Raises: + MissingRequiredArgumentError: If required args are missing + InvalidAuthSchemeError: If an unsupported scheme is provided + InvalidDpopProofError: If DPoP verification fails + VerifyAccessTokenError: If access token verification fails + """ + authorization_header = headers.get("authorization", "") + dpop_proof = headers.get("dpop") + + if not authorization_header: + if getattr(self.options, "dpop_required", False): + raise self._prepare_error( + InvalidAuthSchemeError("Expecting Authorization header with DPoP scheme.") + ) + else : + raise self._prepare_error(MissingAuthorizationError()) + + + parts = authorization_header.split(" ", 1) + if len(parts) < 2: + raise self._prepare_error(MissingAuthorizationError()) + + + try: + scheme, token = authorization_header.split(" ", 1) + except ValueError: + raise self._prepare_error( + MissingAuthorizationError() ) - return self._jwks_data + + + scheme = scheme.strip().lower() + + if getattr(self.options, "dpop_required", False) and scheme != "dpop": + if scheme == "bearer": + raise self._prepare_error( + InvalidAuthSchemeError("Invalid scheme. Expected 'DPoP', but got 'bearer'."), + auth_scheme=scheme + ) + else: + raise self._prepare_error( + InvalidAuthSchemeError("Invalid scheme. Expected 'DPoP' scheme."), + auth_scheme=scheme + ) + if not token.strip(): + raise self._prepare_error(MissingAuthorizationError()) + + + if scheme == "dpop": + if not self.options.dpop_enabled: + raise self._prepare_error(MissingAuthorizationError()) + + if not dpop_proof: + if getattr(self.options, "dpop_required", False): + raise self._prepare_error( + InvalidAuthSchemeError("Expecting Authorization header with DPoP scheme."), + auth_scheme=scheme + ) + else: + raise self._prepare_error( + InvalidDpopProofError("Operation indicated DPoP use but the request has no DPoP HTTP Header"), + auth_scheme=scheme + ) + + if "," in dpop_proof: + raise self._prepare_error( + InvalidDpopProofError("Multiple DPoP proofs are not allowed"), + auth_scheme=scheme + ) + + try: + await get_unverified_header(dpop_proof) + except Exception: + raise self._prepare_error(InvalidDpopProofError("Failed to verify DPoP proof"), auth_scheme=scheme) + + if not http_method or not http_url: + raise self._prepare_error( + InvalidDpopProofError("Operation indicated DPoP use but the request has no http_method or http_url"), auth_scheme=scheme + ) + + try: + access_token_claims = await self.verify_access_token(token) + except VerifyAccessTokenError as e: + raise self._prepare_error(e, auth_scheme=scheme) + + cnf_claim = access_token_claims.get("cnf") + + if not cnf_claim: + raise self._prepare_error( + InvalidDpopProofError("Operation indicated DPoP use but the JWT Access Token has no jkt confirmation claim"), + auth_scheme=scheme + ) + + if not isinstance(cnf_claim, dict): + raise self._prepare_error( + InvalidDpopProofError("Operation indicated DPoP use but the JWT Access Token has invalid confirmation claim format"), + auth_scheme=scheme + ) + try: + await self.verify_dpop_proof( + access_token=token, + proof=dpop_proof, + http_method=http_method, + http_url=http_url + ) + except InvalidDpopProofError as e: + raise self._prepare_error(e, auth_scheme=scheme) + + # DPoP binding verification + jwk_dict = (await get_unverified_header(dpop_proof))["jwk"] + actual_jkt = calculate_jwk_thumbprint(jwk_dict) + expected_jkt = cnf_claim.get("jkt") + + if not expected_jkt: + raise self._prepare_error( + VerifyAccessTokenError("Access token 'cnf' claim missing 'jkt'"), + auth_scheme=scheme + ) + + if expected_jkt != actual_jkt: + raise self._prepare_error( + VerifyAccessTokenError("JWT Access Token confirmation mismatch"), + auth_scheme=scheme + ) + + return access_token_claims + + if scheme == "bearer": + if dpop_proof: + if self.options.dpop_enabled: + raise self._prepare_error( + InvalidAuthSchemeError( + "Operation indicated DPoP use but the request's Authorization HTTP Header scheme is not DPoP" + ), + auth_scheme=scheme + ) + + try: + claims = await self.verify_access_token(token) + if claims.get("cnf") and claims["cnf"].get("jkt"): + if self.options.dpop_enabled: + raise self._prepare_error( + InvalidAuthSchemeError( + "Operation indicated DPoP use but the request's Authorization HTTP Header scheme is not DPoP" + ), + auth_scheme=scheme + ) + + + return claims + except VerifyAccessTokenError as e: + raise self._prepare_error(e, auth_scheme=scheme) + + raise self._prepare_error(MissingAuthorizationError()) async def verify_access_token( self, @@ -71,7 +239,6 @@ async def verify_access_token( required_claims = required_claims or [] - try: header = await get_unverified_header(access_token) kid = header["kid"] @@ -100,7 +267,6 @@ async def verify_access_token( metadata = await self._discover() issuer = metadata["issuer"] - if claims.get("iss") != issuer: raise VerifyAccessTokenError("Issuer mismatch") @@ -120,9 +286,252 @@ async def verify_access_token( if "iat" not in claims: raise VerifyAccessTokenError("Missing 'iat' claim in token") - #Additional required_claims + # Additional required_claims for rc in required_claims: if rc not in claims: raise VerifyAccessTokenError(f"Missing required claim: {rc}") - return claims \ No newline at end of file + return claims + + async def verify_dpop_proof( + self, + access_token: str, + proof: str, + http_method: str, + http_url: str + ) -> Dict[str, Any]: + """ + 1. Single well-formed compact JWS + 2. typ="dpop+jwt", alg∈allowed, alg≠none + 3. jwk header present & public only + 4. Signature verifies with jwk + 5. Validates all required claims + Raises InvalidDpopProofError on any failure. + """ + if not proof: + raise MissingRequiredArgumentError("dpop_proof") + if not access_token: + raise MissingRequiredArgumentError("access_token") + if not http_method or not http_url: + raise MissingRequiredArgumentError("http_method/http_url") + + header = await get_unverified_header(proof) + + if header.get("typ") != "dpop+jwt": + raise InvalidDpopProofError("Unexpected JWT 'typ' header parameter value") + + alg = header.get("alg") + if alg not in self._dpop_algorithms: + raise InvalidDpopProofError(f"Unsupported alg: {alg}") + + jwk_dict = header.get("jwk") + if not jwk_dict or not isinstance(jwk_dict, dict): + raise InvalidDpopProofError("Missing or invalid jwk in header") + + if "d" in jwk_dict: + raise InvalidDpopProofError("Private key material found in jwk header") + + if jwk_dict.get("kty") != "EC": + raise InvalidDpopProofError("Only EC keys are supported for DPoP") + + if jwk_dict.get("crv") != "P-256": + raise InvalidDpopProofError("Only P-256 curve is supported") + + public_key = JsonWebKey.import_key(jwk_dict) + try: + claims = self._dpop_jwt.decode(proof, public_key) + except Exception as e: + raise InvalidDpopProofError(f"JWT signature verification failed: {e}") + + # Checks all required claims are present + self._validate_claims_presence(claims, ["iat", "ath", "htm", "htu", "jti"]) + + jti = claims["jti"] + + if not isinstance(jti, str): + raise InvalidDpopProofError("jti claim must be a string") + + if not jti.strip(): + raise InvalidDpopProofError("jti claim must not be empty") + + + now = int(time.time()) + iat = claims["iat"] + offset = getattr(self.options, "dpop_iat_offset", 300) # default 5 minutes + leeway = getattr(self.options, "dpop_iat_leeway", 30) # default 30 seconds + + if not isinstance(iat, int): + raise InvalidDpopProofError("Invalid iat claim (must be integer)") + + if iat < now - offset or iat > now + leeway: + raise InvalidDpopProofError("DPoP Proof iat is not recent enough") + + if claims["htm"] != http_method: + raise InvalidDpopProofError("DPoP Proof htm mismatch") + + if normalize_url_for_htu(claims["htu"]) != normalize_url_for_htu(http_url): + raise InvalidDpopProofError("DPoP Proof htu mismatch") + + if claims["ath"] != sha256_base64url(access_token): + raise InvalidDpopProofError("DPoP Proof ath mismatch") + + return claims + + # ===== Private Methods ===== + + async def _discover(self) -> Dict[str, Any]: + """Lazy-load OIDC discovery metadata.""" + if self._metadata is None: + self._metadata = await fetch_oidc_metadata( + domain=self.options.domain, + custom_fetch=self.options.custom_fetch + ) + return self._metadata + + async def _load_jwks(self) -> Dict[str, Any]: + """Fetches and caches JWKS data from the OIDC metadata.""" + if self._jwks_data is None: + metadata = await self._discover() + jwks_uri = metadata["jwks_uri"] + self._jwks_data = await fetch_jwks( + jwks_uri=jwks_uri, + custom_fetch=self.options.custom_fetch + ) + return self._jwks_data + + def _validate_claims_presence( + self, + claims: Dict[str, Any], + required_claims: List[str] + ) -> None: + """ + Validates that all required claims are present in the claims dict. + + Args: + claims: The claims dictionary to validate + required_claims: List of claim names that must be present + + Raises: + InvalidDpopProofError: If any required claim is missing + """ + missing_claims = [] + + for claim in required_claims: + if claim not in claims: + missing_claims.append(claim) + + if missing_claims: + if len(missing_claims) == 1: + error_message = f"Missing required claim: {missing_claims[0]}" + else: + error_message = f"Missing required claims: {', '.join(missing_claims)}" + + raise InvalidDpopProofError(error_message) + + def _prepare_error(self, error: BaseAuthError, auth_scheme: Optional[str] = None) -> BaseAuthError: + """ + Prepare an error with WWW-Authenticate headers based on error type and context. + + Args: + error: The error to prepare + auth_scheme: The authentication scheme that was used ("bearer" or "dpop") + """ + error_code = error.get_error_code() + error_description = error.get_error_description() + + www_auth_headers = self._build_www_authenticate( + error_code=error_code if error_code != "unauthorized" else None, + error_description=error_description if error_code != "unauthorized" else None, + auth_scheme=auth_scheme + ) + + headers = {} + www_auth_values = [] + for header_name, header_value in www_auth_headers: + if header_name == "WWW-Authenticate": + www_auth_values.append(header_value) + + if www_auth_values: + headers["WWW-Authenticate"] = ", ".join(www_auth_values) + + error._headers = headers + + return error + + def _build_www_authenticate( + self, + *, + error_code: Optional[str] = None, + error_description: Optional[str] = None, + auth_scheme: Optional[str] = None + ) -> List[Tuple[str, str]]: + """ + Returns one or two ('WWW-Authenticate', ...) tuples based on context. + If dpop_required mode → single DPoP challenge (with optional error params). + Otherwise → Bearer and/or DPoP challenges based on auth_scheme and error. + + Args: + error_code: Error code (e.g., "invalid_token", "invalid_request") + error_description: Error description if any + auth_scheme: The authentication scheme that was used ("bearer" or "dpop") + """ + # If DPoP is disabled, only return Bearer challenges + if not self.options.dpop_enabled: + if error_code and error_code != "unauthorized": + bearer_parts = [] + bearer_parts.append(f'error="{error_code}"') + if error_description: + bearer_parts.append(f'error_description="{error_description}"') + return [("WWW-Authenticate", "Bearer " + ", ".join(bearer_parts))] + return [("WWW-Authenticate", "Bearer")] + + algs = " ".join(self._dpop_algorithms) + dpop_required = getattr(self.options, "dpop_required", False) + + # No error details + if error_code == "unauthorized" or not error_code: + if dpop_required: + return [("WWW-Authenticate", f'DPoP algs="{algs}"')] + return [("WWW-Authenticate", f'Bearer, DPoP algs="{algs}"')] + + if dpop_required: + # DPoP-required mode: Single DPoP challenge with error + dpop_parts = [] + if error_code: + dpop_parts.append(f'error="{error_code}"') + if error_description: + dpop_parts.append(f'error_description="{error_description}"') + dpop_parts.append(f'algs="{algs}"') + dpop_header = "DPoP " + ", ".join(dpop_parts) + return [("WWW-Authenticate", dpop_header)] + + # DPoP-allowed mode: For DPoP errors, always include both challenges + if auth_scheme == "dpop" and error_code: + bearer_header = "Bearer" + dpop_parts = [] + if error_code: + dpop_parts.append(f'error="{error_code}"') + if error_description: + dpop_parts.append(f'error_description="{error_description}"') + dpop_parts.append(f'algs="{algs}"') + dpop_header = "DPoP " + ", ".join(dpop_parts) + return [ + ("WWW-Authenticate", bearer_header), + ("WWW-Authenticate", dpop_header), + ] + + # If auth_scheme is "bearer", include error on Bearer challenge + if auth_scheme == "bearer" and error_code: + bearer_parts = [] + bearer_parts.append(f'error="{error_code}"') + if error_description: + bearer_parts.append(f'error_description="{error_description}"') + bearer_header = "Bearer " + ", ".join(bearer_parts) + dpop_header = f'DPoP algs="{algs}"' + return [("WWW-Authenticate", f'{bearer_header}, {dpop_header}')] + + # Default: no error or unknown context + return [ + ("WWW-Authenticate", "Bearer"), + ("WWW-Authenticate", f'DPoP algs="{algs}"'), + ] \ No newline at end of file diff --git a/packages/auth0_api_python/src/auth0_api_python/config.py b/packages/auth0_api_python/src/auth0_api_python/config.py index de2f4f8..b7cb2ca 100644 --- a/packages/auth0_api_python/src/auth0_api_python/config.py +++ b/packages/auth0_api_python/src/auth0_api_python/config.py @@ -12,13 +12,25 @@ class ApiClientOptions: domain: The Auth0 domain, e.g., "my-tenant.us.auth0.com". audience: The expected 'aud' claim in the token. custom_fetch: Optional callable that can replace the default HTTP fetch logic. + dpop_enabled: Whether DPoP is enabled (default: True for backward compatibility). + dpop_required: Whether DPoP is required (default: False, allows both Bearer and DPoP). + dpop_iat_leeway: Leeway in seconds for DPoP proof iat claim (default: 30). + dpop_iat_offset: Maximum age in seconds for DPoP proof iat claim (default: 300). """ def __init__( self, domain: str, audience: str, - custom_fetch: Optional[Callable[..., object]] = None + custom_fetch: Optional[Callable[..., object]] = None, + dpop_enabled: bool = True, + dpop_required: bool = False, + dpop_iat_leeway: int = 30, + dpop_iat_offset: int = 300, ): self.domain = domain self.audience = audience self.custom_fetch = custom_fetch + self.dpop_enabled = dpop_enabled + self.dpop_required = dpop_required + self.dpop_iat_leeway = dpop_iat_leeway + self.dpop_iat_offset = dpop_iat_offset diff --git a/packages/auth0_api_python/src/auth0_api_python/errors.py b/packages/auth0_api_python/src/auth0_api_python/errors.py index e450059..64013a1 100644 --- a/packages/auth0_api_python/src/auth0_api_python/errors.py +++ b/packages/auth0_api_python/src/auth0_api_python/errors.py @@ -1,21 +1,105 @@ """ -Custom exceptions for auth0-api-python SDK +Custom exceptions for auth0-api-python SDK with HTTP response metadata """ +from typing import Dict, Any, Optional + + +class BaseAuthError(Exception): + """Base class for all auth errors with HTTP response metadata.""" + + def __init__(self, message: str): + super().__init__(message) + self.message = message + self.name = self.__class__.__name__ + self._headers = {} # Will be set by ApiClient._prepare_error + + def get_status_code(self) -> int: + """Return the HTTP status code for this error.""" + raise NotImplementedError("Subclasses must implement get_status_code()") + + def get_error_code(self) -> str: + """Return the OAuth/DPoP error code.""" + raise NotImplementedError("Subclasses must implement get_error_code()") + + def get_error_description(self) -> str: + """Return the error description.""" + return self.message + + def get_headers(self) -> Dict[str, str]: + """Return HTTP headers (including WWW-Authenticate if set).""" + return self._headers + + def to_response_dict(self) -> Dict[str, Any]: + """Convert to a dictionary suitable for JSON response body.""" + return { + "error": self.get_error_code(), + "error_description": self.get_error_description() + } -class MissingRequiredArgumentError(Exception): - """Error raised when a required argument is missing.""" - code = "missing_required_argument_error" +class MissingRequiredArgumentError(BaseAuthError): + """Error raised when a required argument is missing.""" + def __init__(self, argument: str): super().__init__(f"The argument '{argument}' is required but was not provided.") self.argument = argument - self.name = self.__class__.__name__ + + def get_status_code(self) -> int: + return 400 + + def get_error_code(self) -> str: + return "invalid_request" -class VerifyAccessTokenError(Exception): +class VerifyAccessTokenError(BaseAuthError): """Error raised when verifying the access token fails.""" - code = "verify_access_token_error" + + def get_status_code(self) -> int: + return 401 + + def get_error_code(self) -> str: + return "invalid_token" + +class InvalidAuthSchemeError(BaseAuthError): + """Error raised when the provided authentication scheme is unsupported.""" + def __init__(self, message: str): super().__init__(message) - self.name = self.__class__.__name__ + if ":" in message and "'" in message: + self.scheme = message.split("'")[1] + else: + self.scheme = None + + def get_status_code(self) -> int: + return 400 + + def get_error_code(self) -> str: + return "invalid_request" + + +class InvalidDpopProofError(BaseAuthError): + """Error raised when validating a DPoP proof fails.""" + + def get_status_code(self) -> int: + return 400 + + def get_error_code(self) -> str: + return "invalid_dpop_proof" + + +class MissingAuthorizationError(BaseAuthError): + """Authorization header is missing, empty, or malformed.""" + + def __init__(self): + super().__init__("") + + def get_status_code(self) -> int: + return 401 + + def get_error_code(self) -> str: + return "" + + def get_error_description(self) -> str: + return "" + diff --git a/packages/auth0_api_python/src/auth0_api_python/token_utils.py b/packages/auth0_api_python/src/auth0_api_python/token_utils.py index 8f75b98..f528a7e 100644 --- a/packages/auth0_api_python/src/auth0_api_python/token_utils.py +++ b/packages/auth0_api_python/src/auth0_api_python/token_utils.py @@ -1,6 +1,8 @@ import time from typing import Optional, Dict, Any, Union from authlib.jose import JsonWebKey, jwt +import uuid +from .utils import sha256_base64url, normalize_url_for_htu, calculate_jwk_thumbprint # A private RSA JWK for test usage. @@ -81,4 +83,136 @@ async def generate_token( header = {"alg": "RS256", "kid": PRIVATE_JWK["kid"]} token = jwt.encode(header, token_claims, key) - return token + # Ensure we return a string, not bytes + return token.decode('utf-8') if isinstance(token, bytes) else token + + +# A private EC P-256 private key for DPoP proof generation (test only) +PRIVATE_EC_JWK = { + "kty": "EC", + "crv": "P-256", + "x": "MKBCTNIcKUSDii11ySs3526iDZ8AiTo7Tu6KPAqv7D4", + "y": "4Etl6SRW2YiLUrN5vfvVHuhp7x8PxltmWWlbbM4IFyM", + "d": "870MB6gfuTJ4HtUnUvYMyJpr5eUZNP4Bk43bVdj3eAE" +} + + +async def generate_dpop_proof( + access_token: str, + http_method: str, + http_url: str, + jti: Optional[str] = None, + iat: bool = True, + claims: Optional[Dict[str, Any]] = None, + header_overrides: Optional[Dict[str, Any]] = None, + iat_time: Optional[int] = None +) -> str: + """ + Generates a real ES256-signed DPoP proof JWT using the EC private key above. + + Args: + access_token: The access token to create proof for (used for ath claim). + http_method: The HTTP method (e.g., "GET", "POST") for htm claim. + http_url: The HTTP URL for htu claim. + jti: The unique identifier for the proof. If omitted, generates random UUID. + iat: Whether to set the 'iat' (issued at) claim. If False, skip it. + claims: Additional custom claims to merge into the proof. + header_overrides: Override header parameters (e.g., for testing invalid headers). + iat_time: Fixed time for iat claim (for testing). If None, uses current time. + + Returns: + An ES256-signed DPoP proof JWT string. + + Example usage: + proof = await generate_dpop_proof( + access_token="eyJ...", + http_method="GET", + http_url="https://api.example.com/resource", + iat=False, # Skip iat for testing + claims={"custom": "claim"} + ) + """ + + + proof_claims = dict(claims or {}) + + if iat: + proof_claims["iat"] = iat_time if iat_time is not None else int(time.time()) + + if jti is not None: + proof_claims["jti"] = jti + else: + proof_claims["jti"] = str(uuid.uuid4()) + + proof_claims["htm"] = http_method + proof_claims["htu"] = normalize_url_for_htu(http_url) + proof_claims["ath"] = sha256_base64url(access_token) + + + public_jwk = {k: v for k, v in PRIVATE_EC_JWK.items() if k != "d"} + + + header = { + "alg": "ES256", + "typ": "dpop+jwt", + "jwk": public_jwk + } + + + if header_overrides: + header.update(header_overrides) + + key = JsonWebKey.import_key(PRIVATE_EC_JWK) + token = jwt.encode(header, proof_claims, key) + # Ensure we return a string, not bytes + return token.decode('utf-8') if isinstance(token, bytes) else token + + +async def generate_token_with_cnf( + domain: str, + user_id: str, + audience: str, + jkt_thumbprint: Optional[str] = None, + **kwargs +) -> str: + """ + Generates an access token with cnf (confirmation) claim for DPoP binding. + Extends the existing generate_token() function with DPoP support. + + Args: + domain: The Auth0 domain (used if issuer is not False). + user_id: The 'sub' claim in the token. + audience: The 'aud' claim in the token. + jkt_thumbprint: JWK thumbprint to include in cnf claim. If None, calculates from PRIVATE_EC_JWK. + **kwargs: Additional arguments passed to generate_token(). + + Returns: + A RS256-signed JWT string with cnf claim. + + Example usage: + token = await generate_token_with_cnf( + domain="auth0.local", + user_id="user123", + audience="my-api", + jkt_thumbprint="custom_thumbprint" + ) + """ + + + if jkt_thumbprint is None: + public_jwk = {k: v for k, v in PRIVATE_EC_JWK.items() if k != "d"} + jkt_thumbprint = calculate_jwk_thumbprint(public_jwk) + + + existing_claims = kwargs.get('claims', {}) + cnf_claims = dict(existing_claims) + cnf_claims["cnf"] = {"jkt": jkt_thumbprint} + kwargs['claims'] = cnf_claims + + + return await generate_token( + domain=domain, + user_id=user_id, + audience=audience, + **kwargs + ) diff --git a/packages/auth0_api_python/src/auth0_api_python/utils.py b/packages/auth0_api_python/src/auth0_api_python/utils.py index 2d66ecb..ef549f9 100644 --- a/packages/auth0_api_python/src/auth0_api_python/utils.py +++ b/packages/auth0_api_python/src/auth0_api_python/utils.py @@ -6,8 +6,12 @@ import httpx import base64 import json +import hashlib +import uuid from typing import Any, Dict, Optional, Callable, Union +from urllib.parse import urlparse, urlunparse + async def fetch_oidc_metadata( domain: str, custom_fetch: Optional[Callable[..., Any]] = None @@ -85,4 +89,70 @@ def remove_bytes_prefix(s: str) -> str: """If the string looks like b'eyJh...', remove the leading b' and trailing '.""" if s.startswith("b'"): return s[2:] # cut off the leading b' - return s \ No newline at end of file + return s + +def normalize_url_for_htu(raw_url: str) -> str: + """ + Normalize URL for DPoP htu comparison following RFC 3986. + Matches the level of normalization that browsers typically do. + """ + p = urlparse(raw_url) + + # Lowercase scheme and netloc (host) + scheme = p.scheme.lower() + netloc = p.netloc.lower() + + # Remove default ports + if scheme == "http" and netloc.endswith(":80"): + netloc = netloc[:-3] + elif scheme == "https" and netloc.endswith(":443"): + netloc = netloc[:-4] + + # Ensure non-empty path for http(s) + path = p.path + if scheme in ("http", "https") and not path: + path = "/" + + return urlunparse((scheme, netloc, path, "", "", "")) + + +def sha256_base64url(input_str: Union[str, bytes]) -> str: + """ + Compute SHA-256 digest of the input string and return a + Base64URL-encoded string *without* padding. + """ + if isinstance(input_str, str): + digest = hashlib.sha256(input_str.encode("utf-8")).digest() + else: + digest = hashlib.sha256(input_str).digest() + b64 = base64.urlsafe_b64encode(digest).decode("utf-8") + return b64.rstrip("=") + +def calculate_jwk_thumbprint(jwk: Dict[str, str]) -> str: + """ + Compute the RFC 7638 JWK thumbprint for a public JWK. + + - For EC keys, includes only: crv, kty, x, y + - Serializes with no whitespace, keys sorted lexicographically + - Hashes with SHA-256 and returns base64url-encoded string without padding + """ + kty = jwk.get("kty") + + if kty == "EC": + if not all(k in jwk for k in ["crv", "x", "y"]): + raise ValueError("EC key missing required parameters") + members = ("crv", "kty", "x", "y") + else: + raise ValueError(f"{kty}(Key Type) Parameter missing or unsupported ") + + # order the members and filter out any missing keys + ordered = {k: jwk[k] for k in members if k in jwk} + + # Serialize to JSON with no whitespace, sorted keys + thumbprint_json = json.dumps(ordered, separators=(",", ":"), sort_keys=True) + + #Using SHA-256 to hash the JSON string + digest = hashlib.sha256(thumbprint_json.encode("utf-8")).digest() + + # Base64URL-encode the digest and remove padding + return base64.urlsafe_b64encode(digest).decode("utf-8").rstrip("=") \ No newline at end of file diff --git a/packages/auth0_api_python/tests/test_api_client.py b/packages/auth0_api_python/tests/test_api_client.py index 8cc3bce..60a1beb 100644 --- a/packages/auth0_api_python/tests/test_api_client.py +++ b/packages/auth0_api_python/tests/test_api_client.py @@ -1,12 +1,16 @@ import pytest +import base64 +import json +import time from pytest_httpx import HTTPXMock -from unittest.mock import AsyncMock, patch from src.auth0_api_python.api_client import ApiClient from src.auth0_api_python.config import ApiClientOptions -from src.auth0_api_python.errors import MissingRequiredArgumentError, VerifyAccessTokenError -from src.auth0_api_python.token_utils import generate_token +from src.auth0_api_python.errors import MissingRequiredArgumentError, VerifyAccessTokenError, InvalidDpopProofError, InvalidAuthSchemeError, MissingAuthorizationError +from src.auth0_api_python.token_utils import generate_token, generate_dpop_proof, generate_token_with_cnf, PRIVATE_JWK, PRIVATE_EC_JWK +# Create public RSA JWK by excluding private key components +PUBLIC_RSA_JWK = {k: v for k, v in PRIVATE_JWK.items() if k not in ["d", "p", "q", "dp", "dq", "qi"]} @pytest.mark.asyncio async def test_init_missing_args(): @@ -388,3 +392,1044 @@ async def test_verify_access_token_fail_no_audience_config(): error_str = str(err.value).lower() assert "audience" in error_str and ("required" in error_str or "not provided" in error_str) + + + +# DPOP PROOF VERIFICATION TESTS - Core Functionality & Validation + +# --- Core Success Tests --- + +@pytest.mark.asyncio +async def test_verify_dpop_proof_successfully(): + """ + Test that a valid DPoP proof is verified successfully by ApiClient. + """ + access_token = "test_token" + dpop_proof = await generate_dpop_proof( + access_token=access_token, + http_method="GET", + http_url="https://api.example.com/resource" + ) + + api_client = ApiClient( + ApiClientOptions(domain="auth0.local", audience="my-audience") + ) + + # Verify the DPoP proof + claims = await api_client.verify_dpop_proof( + access_token=access_token, + proof=dpop_proof, + http_method="GET", + http_url="https://api.example.com/resource" + ) + assert claims["jti"] # Verify it has the required jti claim + + +@pytest.mark.asyncio +async def test_verify_dpop_proof_fail_no_access_token(): + """ + Test that verify_dpop_proof fails when access_token is missing. + """ + dpop_proof = await generate_dpop_proof( + access_token="test_token", + http_method="GET", + http_url="https://api.example.com/resource" + ) + + api_client = ApiClient( + ApiClientOptions(domain="auth0.local", audience="my-audience") + ) + + with pytest.raises(MissingRequiredArgumentError) as err: + await api_client.verify_dpop_proof( + access_token="", # Empty access token + proof=dpop_proof, + http_method="GET", + http_url="https://api.example.com/resource" + ) + + assert "access_token" in str(err.value).lower() + + +@pytest.mark.asyncio +async def test_verify_dpop_proof_fail_no_dpop_proof(): + """ + Test that verify_dpop_proof fails when dpop_proof is missing. + """ + api_client = ApiClient( + ApiClientOptions(domain="auth0.local", audience="my-audience") + ) + + with pytest.raises(MissingRequiredArgumentError) as err: + await api_client.verify_dpop_proof( + access_token="test_token", + proof="", # Empty proof + http_method="GET", + http_url="https://api.example.com/resource" + ) + + assert "dpop_proof" in str(err.value).lower() + + +@pytest.mark.asyncio +async def test_verify_dpop_proof_fail_no_http_method_url(): + """ + Test that verify_dpop_proof fails when http_method or http_url is missing. + """ + access_token = "test_token" + dpop_proof = await generate_dpop_proof( + access_token=access_token, + http_method="GET", + http_url="https://api.example.com/resource" + ) + + api_client = ApiClient( + ApiClientOptions(domain="auth0.local", audience="my-audience") + ) + + with pytest.raises(MissingRequiredArgumentError) as err: + await api_client.verify_dpop_proof( + access_token=access_token, + proof=dpop_proof, + http_method="", # Empty method + http_url="https://api.example.com/resource" + ) + + assert "http_method" in str(err.value).lower() or "http_url" in str(err.value).lower() + + +# --- Header Validation Tests --- + +@pytest.mark.asyncio +async def test_verify_dpop_proof_fail_no_typ(): + """ + Test that a DPoP proof missing 'typ' header fails verification. + """ + access_token = "test_token" + dpop_proof = await generate_dpop_proof( + access_token=access_token, + http_method="GET", + http_url="https://api.example.com/resource", + header_overrides={"typ": None} # Remove typ header + ) + + api_client = ApiClient( + ApiClientOptions(domain="auth0.local", audience="my-audience") + ) + + with pytest.raises(InvalidDpopProofError) as err: + await api_client.verify_dpop_proof( + access_token=access_token, + proof=dpop_proof, + http_method="GET", + http_url="https://api.example.com/resource" + ) + + assert "typ" in str(err.value).lower() + + +@pytest.mark.asyncio +async def test_verify_dpop_proof_fail_invalid_typ(): + """ + Test that a DPoP proof with invalid 'typ' header fails verification. + """ + access_token = "test_token" + dpop_proof = await generate_dpop_proof( + access_token=access_token, + http_method="GET", + http_url="https://api.example.com/resource", + header_overrides={"typ": "jwt"} # Wrong typ value + ) + + api_client = ApiClient( + ApiClientOptions(domain="auth0.local", audience="my-audience") + ) + + with pytest.raises(InvalidDpopProofError) as err: + await api_client.verify_dpop_proof( + access_token=access_token, + proof=dpop_proof, + http_method="GET", + http_url="https://api.example.com/resource" + ) + + assert "typ" in str(err.value).lower() + + +@pytest.mark.asyncio +async def test_verify_dpop_proof_fail_invalid_alg(): + """ + Test that a DPoP proof with unsupported algorithm fails verification. + """ + + + access_token = "test_token" + + # First generate a valid DPoP proof + valid_proof = await generate_dpop_proof( + access_token=access_token, + http_method="GET", + http_url="https://api.example.com/resource" + ) + + # Manually craft an invalid proof by modifying the algorithm + parts = valid_proof.split('.') + header = json.loads(base64.urlsafe_b64decode(parts[0] + '==').decode('utf-8')) + header['alg'] = 'RS256' # Invalid algorithm for DPoP (should be ES256) + + # Re-encode the header + modified_header = base64.urlsafe_b64encode( + json.dumps(header, separators=(',', ':')).encode('utf-8') + ).decode('utf-8').rstrip('=') + + # Create invalid proof with modified header but same payload and signature + invalid_proof = f"{modified_header}.{parts[1]}.{parts[2]}" + + api_client = ApiClient( + ApiClientOptions(domain="auth0.local", audience="my-audience") + ) + + with pytest.raises(InvalidDpopProofError) as err: + await api_client.verify_dpop_proof( + access_token=access_token, + proof=invalid_proof, + http_method="GET", + http_url="https://api.example.com/resource" + ) + + assert "alg" in str(err.value).lower() + + +@pytest.mark.asyncio +async def test_verify_dpop_proof_fail_no_jwk(): + """ + Test that a DPoP proof missing 'jwk' header fails verification. + """ + access_token = "test_token" + dpop_proof = await generate_dpop_proof( + access_token=access_token, + http_method="GET", + http_url="https://api.example.com/resource", + header_overrides={"jwk": None} # Remove jwk header + ) + + api_client = ApiClient( + ApiClientOptions(domain="auth0.local", audience="my-audience") + ) + + with pytest.raises(InvalidDpopProofError) as err: + await api_client.verify_dpop_proof( + access_token=access_token, + proof=dpop_proof, + http_method="GET", + http_url="https://api.example.com/resource" + ) + + assert "jwk" in str(err.value).lower() + + +@pytest.mark.asyncio +async def test_verify_dpop_proof_fail_invalid_jwk_format(): + """ + Test that a DPoP proof with invalid 'jwk' format fails verification. + """ + access_token = "test_token" + dpop_proof = await generate_dpop_proof( + access_token=access_token, + http_method="GET", + http_url="https://api.example.com/resource", + header_overrides={"jwk": "invalid_jwk"} # Invalid jwk format + ) + + api_client = ApiClient( + ApiClientOptions(domain="auth0.local", audience="my-audience") + ) + + with pytest.raises(InvalidDpopProofError) as err: + await api_client.verify_dpop_proof( + access_token=access_token, + proof=dpop_proof, + http_method="GET", + http_url="https://api.example.com/resource" + ) + + assert "jwk" in str(err.value).lower() + + +@pytest.mark.asyncio +async def test_verify_dpop_proof_fail_private_key_in_jwk(): + """ + Test that a DPoP proof with private key material in jwk fails verification. + """ + + access_token = "test_token" + # Include private key material (the 'd' parameter) + invalid_jwk = dict(PRIVATE_EC_JWK) # This includes the 'd' parameter + + dpop_proof = await generate_dpop_proof( + access_token=access_token, + http_method="GET", + http_url="https://api.example.com/resource", + header_overrides={"jwk": invalid_jwk} # JWK with private key material + ) + + api_client = ApiClient( + ApiClientOptions(domain="auth0.local", audience="my-audience") + ) + + with pytest.raises(InvalidDpopProofError) as err: + await api_client.verify_dpop_proof( + access_token=access_token, + proof=dpop_proof, + http_method="GET", + http_url="https://api.example.com/resource" + ) + + assert "private key" in str(err.value).lower() + + +# --- IAT (Issued At Time) Validation Tests --- + +@pytest.mark.asyncio +async def test_verify_dpop_proof_fail_no_iat(): + """ + Test that a DPoP proof missing 'iat' claim fails verification. + """ + access_token = "test_token" + dpop_proof = await generate_dpop_proof( + access_token=access_token, + http_method="GET", + http_url="https://api.example.com/resource", + iat=False # Skip iat claim + ) + + api_client = ApiClient( + ApiClientOptions(domain="auth0.local", audience="my-audience") + ) + + with pytest.raises(InvalidDpopProofError) as err: + await api_client.verify_dpop_proof( + access_token=access_token, + proof=dpop_proof, + http_method="GET", + http_url="https://api.example.com/resource" + ) + + assert "iat" in str(err.value).lower() + + +@pytest.mark.asyncio +async def test_verify_dpop_proof_fail_invalid_iat_timing(): + """ + Test that a DPoP proof with invalid 'iat' timing fails verification. + """ + access_token = "test_token" + # Use a future timestamp (more than leeway allows) + future_time = int(time.time()) + 3600 # 1 hour in the future + dpop_proof = await generate_dpop_proof( + access_token=access_token, + http_method="GET", + http_url="https://api.example.com/resource", + iat_time=future_time # Invalid future timestamp + ) + + api_client = ApiClient( + ApiClientOptions(domain="auth0.local", audience="my-audience") + ) + + with pytest.raises(InvalidDpopProofError) as err: + await api_client.verify_dpop_proof( + access_token=access_token, + proof=dpop_proof, + http_method="GET", + http_url="https://api.example.com/resource" + ) + + assert "iat" in str(err.value).lower() or "time" in str(err.value).lower() + + +@pytest.mark.asyncio +async def test_verify_dpop_proof_iat_exact_boundary_conditions(): + """ + Test IAT timing validation at exact boundary conditions. + """ + access_token = "test_token" + + # Test with timestamp exactly at the leeway boundary (should pass) + current_time = int(time.time()) + boundary_time = current_time + 30 # Exactly at default leeway limit + + dpop_proof = await generate_dpop_proof( + access_token=access_token, + http_method="GET", + http_url="https://api.example.com/resource", + iat_time=boundary_time + ) + + api_client = ApiClient( + ApiClientOptions(domain="auth0.local", audience="my-audience") + ) + + # Should succeed as it's within leeway + result = await api_client.verify_dpop_proof( + access_token=access_token, + proof=dpop_proof, + http_method="GET", + http_url="https://api.example.com/resource" + ) + + assert result is not None + + +@pytest.mark.asyncio +async def test_verify_dpop_proof_iat_past_offset_boundary(): + """ + Test IAT validation with timestamps too far in the past. + """ + access_token = "test_token" + # Use a timestamp too far in the past (beyond acceptable skew) + past_time = int(time.time()) - 3600 # 1 hour ago + dpop_proof = await generate_dpop_proof( + access_token=access_token, + http_method="GET", + http_url="https://api.example.com/resource", + iat_time=past_time + ) + + api_client = ApiClient( + ApiClientOptions(domain="auth0.local", audience="my-audience") + ) + + with pytest.raises(InvalidDpopProofError) as err: + await api_client.verify_dpop_proof( + access_token=access_token, + proof=dpop_proof, + http_method="GET", + http_url="https://api.example.com/resource" + ) + + assert "iat" in str(err.value).lower() or "time" in str(err.value).lower() + + +@pytest.mark.asyncio +async def test_verify_dpop_proof_iat_clock_skew_scenarios(): + """ + Test IAT validation with various clock skew scenarios. + """ + access_token = "test_token" + current_time = int(time.time()) + + # Test within acceptable skew (should pass) + dpop_proof = await generate_dpop_proof( + access_token=access_token, + http_method="GET", + http_url="https://api.example.com/resource", + iat_time=current_time - 30 # 30 seconds ago, should be acceptable + ) + + api_client = ApiClient( + ApiClientOptions(domain="auth0.local", audience="my-audience") + ) + + # This should succeed due to clock skew tolerance + result = await api_client.verify_dpop_proof( + access_token=access_token, + proof=dpop_proof, + http_method="GET", + http_url="https://api.example.com/resource" + ) + assert result is not None + + +# --- JTI (JWT ID) Validation Tests --- + +@pytest.mark.asyncio +async def test_verify_dpop_proof_fail_no_jti(): + """ + Test that a DPoP proof missing 'jti' claim fails verification. + """ + access_token = "test_token" + dpop_proof = await generate_dpop_proof( + access_token=access_token, + http_method="GET", + http_url="https://api.example.com/resource", + jti="" # Empty jti claim + ) + + api_client = ApiClient( + ApiClientOptions(domain="auth0.local", audience="my-audience") + ) + + with pytest.raises(InvalidDpopProofError) as err: + await api_client.verify_dpop_proof( + access_token=access_token, + proof=dpop_proof, + http_method="GET", + http_url="https://api.example.com/resource" + ) + + assert "jti" in str(err.value).lower() + +@pytest.mark.asyncio +async def test_verify_dpop_proof_jti_uniqueness_scenarios(): + """ + Test JTI uniqueness and replay protection scenarios. + """ + access_token = "test_token" + + # Generate DPoP proof with specific JTI using the jti parameter + custom_jti = "unique-jti-12345" + dpop_proof = await generate_dpop_proof( + access_token=access_token, + http_method="GET", + http_url="https://api.example.com/resource", + jti=custom_jti # Use jti parameter instead of claims + ) + + api_client = ApiClient( + ApiClientOptions(domain="auth0.local", audience="my-audience") + ) + + # First verification should succeed + result = await api_client.verify_dpop_proof( + access_token=access_token, + proof=dpop_proof, + http_method="GET", + http_url="https://api.example.com/resource" + ) + + assert result is not None + assert result["jti"] == custom_jti + + +@pytest.mark.asyncio +async def test_verify_dpop_proof_fail_htm_mismatch(): + """ + Test that a DPoP proof with mismatched 'htm' claim fails verification. + """ + access_token = "test_token" + dpop_proof = await generate_dpop_proof( + access_token=access_token, + http_method="POST", # Generate proof for POST + http_url="https://api.example.com/resource", + ) + + api_client = ApiClient( + ApiClientOptions(domain="auth0.local", audience="my-audience") + ) + + with pytest.raises(InvalidDpopProofError) as err: + await api_client.verify_dpop_proof( + access_token=access_token, + proof=dpop_proof, + http_method="GET", # But verify with GET + http_url="https://api.example.com/resource" + ) + + assert "htm" in str(err.value).lower() or "method" in str(err.value).lower() + + +# --- HTU (HTTP URI) Validation Tests --- + +@pytest.mark.asyncio +async def test_verify_dpop_proof_fail_htu_mismatch(): + """ + Test that a DPoP proof with mismatched 'htu' claim fails verification. + """ + access_token = "test_token" + dpop_proof = await generate_dpop_proof( + access_token=access_token, + http_method="GET", + http_url="https://api.example.com/wrong-resource", # Generate proof for wrong URL + ) + + api_client = ApiClient( + ApiClientOptions(domain="auth0.local", audience="my-audience") + ) + + with pytest.raises(InvalidDpopProofError) as err: + await api_client.verify_dpop_proof( + access_token=access_token, + proof=dpop_proof, + http_method="GET", + http_url="https://api.example.com/resource" # But verify with correct URL + ) + + assert "htu" in str(err.value).lower() or "url" in str(err.value).lower() + + +@pytest.mark.asyncio +async def test_verify_dpop_proof_htu_url_normalization_case_sensitivity(): + """ + Test HTU URL normalization handles case sensitivity correctly. + """ + access_token = "test_token" + + # Test with different case in domain (should be normalized and pass) + dpop_proof = await generate_dpop_proof( + access_token=access_token, + http_method="GET", + http_url="https://API.EXAMPLE.COM/resource" # Uppercase domain + ) + + api_client = ApiClient( + ApiClientOptions(domain="auth0.local", audience="my-audience") + ) + + # This should succeed due to URL normalization + result = await api_client.verify_dpop_proof( + access_token=access_token, + proof=dpop_proof, + http_method="GET", + http_url="https://api.example.com/resource" # Lowercase domain + ) + assert result is not None + + +@pytest.mark.asyncio +async def test_verify_dpop_proof_htu_trailing_slash_normalization(): + """ + Test HTU URL normalization with trailing slashes: should fail because path difference is significant. + """ + access_token = "test_token" + # Generate proof with trailing slash + dpop_proof = await generate_dpop_proof( + access_token=access_token, + http_method="GET", + http_url="https://api.example.com/resource/" + ) + api_client = ApiClient(ApiClientOptions(domain="auth0.local", audience="my-audience")) + with pytest.raises(InvalidDpopProofError): + await api_client.verify_dpop_proof( + access_token=access_token, + proof=dpop_proof, + http_method="GET", + http_url="https://api.example.com/resource" + ) + + + +@pytest.mark.asyncio +async def test_verify_dpop_proof_htu_query_parameters(): + """ + Test HTU URL validation with query parameters - normalized behavior. + Query parameters are stripped during normalization, so different params should succeed. + """ + access_token = "test_token" + + # Test with query parameters (should be normalized) + dpop_proof = await generate_dpop_proof( + access_token=access_token, + http_method="GET", + http_url="https://api.example.com/resource?param1=value1" # With query params + ) + + api_client = ApiClient( + ApiClientOptions(domain="auth0.local", audience="my-audience") + ) + + # This should succeed due to URL normalization + result = await api_client.verify_dpop_proof( + access_token=access_token, + proof=dpop_proof, + http_method="GET", + http_url="https://api.example.com/resource?param2=value2" # Different query params + ) + assert result is not None + + +@pytest.mark.asyncio +async def test_verify_dpop_proof_htu_port_numbers(): + """ + Test HTU URL validation with explicit port numbers - normalized behavior. + Default ports (443 for HTTPS, 80 for HTTP) are stripped during normalization. + """ + access_token = "test_token" + + # Test with explicit default port (should be normalized) + dpop_proof = await generate_dpop_proof( + access_token=access_token, + http_method="GET", + http_url="https://api.example.com:443/resource" # Explicit HTTPS port + ) + + api_client = ApiClient( + ApiClientOptions(domain="auth0.local", audience="my-audience") + ) + + # This should succeed due to URL normalization + result = await api_client.verify_dpop_proof( + access_token=access_token, + proof=dpop_proof, + http_method="GET", + http_url="https://api.example.com/resource" # Implicit HTTPS port + ) + assert result is not None + + +@pytest.mark.asyncio +async def test_verify_dpop_proof_htu_fragment_handling(): + """ + Test HTU URL validation ignores fragments. + """ + access_token = "test_token" + + # Test with fragment (should be ignored) + dpop_proof = await generate_dpop_proof( + access_token=access_token, + http_method="GET", + http_url="https://api.example.com/resource#fragment1" # With fragment + ) + + api_client = ApiClient( + ApiClientOptions(domain="auth0.local", audience="my-audience") + ) + + # This should succeed as fragments are ignored + result = await api_client.verify_dpop_proof( + access_token=access_token, + proof=dpop_proof, + http_method="GET", + http_url="https://api.example.com/resource#fragment2" # Different fragment + ) + assert result is not None + + +@pytest.mark.asyncio +async def test_verify_dpop_proof_fail_ath_mismatch(): + """ + Test that a DPoP proof with mismatched 'ath' claim fails verification. + """ + access_token = "test_token" + wrong_token = "wrong_token" + + dpop_proof = await generate_dpop_proof( + access_token=wrong_token, # Generate proof for wrong token + http_method="GET", + http_url="https://api.example.com/resource", + ) + + api_client = ApiClient( + ApiClientOptions(domain="auth0.local", audience="my-audience") + ) + + with pytest.raises(InvalidDpopProofError) as err: + await api_client.verify_dpop_proof( + access_token=access_token, # But verify with correct token + proof=dpop_proof, + http_method="GET", + http_url="https://api.example.com/resource" + ) + + assert "ath" in str(err.value).lower() or "hash" in str(err.value).lower() + +# VERIFY_REQUEST TESTS + +# --- Success Tests --- + +@pytest.mark.asyncio +async def test_verify_request_bearer_scheme_success(httpx_mock: HTTPXMock): + """ + Test successful Bearer token verification through verify_request. + """ + # Mock OIDC discovery + httpx_mock.add_response( + method="GET", + url="https://auth0.local/.well-known/openid-configuration", + json={ + "jwks_uri": "https://auth0.local/.well-known/jwks.json", + "issuer": "https://auth0.local/", + }, + ) + + # Mock JWKS endpoint + httpx_mock.add_response( + method="GET", + url="https://auth0.local/.well-known/jwks.json", + json={"keys": [PUBLIC_RSA_JWK]}, + ) + + # Generate a valid Bearer token + token = await generate_token( + domain="auth0.local", + user_id="test_user", + audience="my-audience", + ) + + api_client = ApiClient( + ApiClientOptions(domain="auth0.local", audience="my-audience") + ) + + # Test Bearer scheme + result = await api_client.verify_request( + headers={"authorization": f"Bearer {token}"}, + http_method="GET", + http_url="https://api.example.com/resource" + ) + + assert "sub" in result + assert result["aud"] == "my-audience" + assert result["iss"] == "https://auth0.local/" + + +@pytest.mark.asyncio +async def test_verify_request_dpop_scheme_success(httpx_mock: HTTPXMock): + """ + Test successful DPoP token verification through verify_request. + """ + # Mock OIDC discovery + httpx_mock.add_response( + method="GET", + url="https://auth0.local/.well-known/openid-configuration", + json={ + "jwks_uri": "https://auth0.local/.well-known/jwks.json", + "issuer": "https://auth0.local/", + }, + ) + + # Mock JWKS endpoint + httpx_mock.add_response( + method="GET", + url="https://auth0.local/.well-known/jwks.json", + json={"keys": [PUBLIC_RSA_JWK]}, + ) + + # Generate DPoP bound token and proof + access_token = await generate_token_with_cnf( + domain="auth0.local", + user_id="test_user", + audience="my-audience", + ) + dpop_proof = await generate_dpop_proof( + access_token=access_token, + http_method="GET", + http_url="https://api.example.com/resource" + ) + + api_client = ApiClient( + ApiClientOptions(domain="auth0.local", audience="my-audience") + ) + + # Test DPoP scheme + result = await api_client.verify_request( + headers={"authorization": f"DPoP {access_token}", "dpop": dpop_proof}, + http_method="GET", + http_url="https://api.example.com/resource" + ) + + assert "sub" in result + assert result["aud"] == "my-audience" + assert result["iss"] == "https://auth0.local/" + + +# --- Configuration & Error Handling Tests --- + +@pytest.mark.asyncio +async def test_verify_request_fail_dpop_required_mode(): + """ + Test that Bearer tokens are rejected when DPoP is required. + """ + # Generate a valid Bearer token + token = await generate_token( + domain="auth0.local", + user_id="test_user", + audience="my-audience", + ) + + api_client = ApiClient( + ApiClientOptions( + domain="auth0.local", + audience="my-audience", + dpop_required=True # Require DPoP + ) + ) + + with pytest.raises(InvalidAuthSchemeError) as err: + await api_client.verify_request( + headers={"authorization": f"Bearer {token}"}, + http_method="GET", + http_url="https://api.example.com/resource" + ) + + assert "dpop" in str(err.value).lower() or "bearer" in str(err.value).lower() + + +@pytest.mark.asyncio +async def test_verify_request_fail_dpop_enabled_bearer_with_cnf_conflict(httpx_mock: HTTPXMock): + """ + Test that Bearer tokens with cnf claim are rejected when DPoP is enabled. + """ + # Mock OIDC discovery + httpx_mock.add_response( + method="GET", + url="https://auth0.local/.well-known/openid-configuration", + json={ + "jwks_uri": "https://auth0.local/.well-known/jwks.json", + "issuer": "https://auth0.local/", + }, + ) + + # Mock JWKS endpoint + httpx_mock.add_response( + method="GET", + url="https://auth0.local/.well-known/jwks.json", + json={"keys": [PUBLIC_RSA_JWK]}, + ) + + # Generate a token with cnf claim (DPoP-bound token) + token = await generate_token_with_cnf( + domain="auth0.local", + user_id="test_user", + audience="my-audience", + ) + + api_client = ApiClient( + ApiClientOptions( + domain="auth0.local", + audience="my-audience", + dpop_enabled=True # DPoP enabled + ) + ) + + with pytest.raises(InvalidAuthSchemeError) as err: + await api_client.verify_request( + headers={"authorization": f"Bearer {token}"}, + http_method="GET", + http_url="https://api.example.com/resource" + ) + + assert "cnf" in str(err.value).lower() or "dpop" in str(err.value).lower() + + +@pytest.mark.asyncio +async def test_verify_request_fail_dpop_disabled(): + """ + Test that DPoP tokens are rejected when DPoP is disabled. + """ + access_token = "test_token" + dpop_proof = await generate_dpop_proof( + access_token=access_token, + http_method="GET", + http_url="https://api.example.com/resource" + ) + + api_client = ApiClient( + ApiClientOptions( + domain="auth0.local", + audience="my-audience", + dpop_enabled=False # DPoP disabled + ) + ) + + with pytest.raises(MissingAuthorizationError) as err: + await api_client.verify_request( + headers={"authorization": f"DPoP {access_token}", "dpop": dpop_proof}, + http_method="GET", + http_url="https://api.example.com/resource" + ) + + # MissingAuthorizationError doesn't have a specific message for disabled DPoP + assert isinstance(err.value, MissingAuthorizationError) + + +@pytest.mark.asyncio +async def test_verify_request_fail_missing_authorization_header(): + """ + Test that requests without Authorization header are rejected. + """ + api_client = ApiClient( + ApiClientOptions(domain="auth0.local", audience="my-audience") + ) + + with pytest.raises(MissingAuthorizationError) as err: + await api_client.verify_request( + headers={}, + http_method="GET", + http_url="https://api.example.com/resource" + ) + + +@pytest.mark.asyncio +async def test_verify_request_fail_malformed_authorization_header(): + """ + Test that malformed Authorization headers are rejected. + """ + api_client = ApiClient( + ApiClientOptions(domain="auth0.local", audience="my-audience") + ) + + with pytest.raises(MissingAuthorizationError) as err: + await api_client.verify_request( + headers={"authorization": "InvalidFormat"}, # Missing scheme and token + http_method="GET", + http_url="https://api.example.com/resource" + ) + + +@pytest.mark.asyncio +async def test_verify_request_fail_unsupported_scheme(): + """ + Test that unsupported authentication schemes are rejected. + """ + api_client = ApiClient( + ApiClientOptions(domain="auth0.local", audience="my-audience") + ) + + with pytest.raises(MissingAuthorizationError) as err: + await api_client.verify_request( + headers={"authorization": "Basic dXNlcjpwYXNz"}, + http_method="GET", + http_url="https://api.example.com/resource" + ) + + +@pytest.mark.asyncio +async def test_verify_request_fail_missing_dpop_header(): + """ + Test that DPoP scheme requests without DPoP header are rejected. + """ + access_token = "test_token" + + api_client = ApiClient( + ApiClientOptions(domain="auth0.local", audience="my-audience") + ) + + with pytest.raises(InvalidDpopProofError) as err: + await api_client.verify_request( + headers={"authorization": f"DPoP {access_token}"}, # Missing DPoP header + http_method="GET", + http_url="https://api.example.com/resource" + ) + + assert "dpop" in str(err.value).lower() or "proof" in str(err.value).lower() + + +@pytest.mark.asyncio +async def test_verify_request_fail_multiple_dpop_proofs(): + """ + Test that requests with multiple DPoP proofs are rejected. + """ + access_token = "test_token" + dpop_proof1 = await generate_dpop_proof( + access_token=access_token, + http_method="GET", + http_url="https://api.example.com/resource" + ) + dpop_proof2 = await generate_dpop_proof( + access_token=access_token, + http_method="GET", + http_url="https://api.example.com/resource" + ) + + api_client = ApiClient( + ApiClientOptions(domain="auth0.local", audience="my-audience") + ) + + with pytest.raises(InvalidDpopProofError) as err: + await api_client.verify_request( + headers={"authorization": f"DPoP {access_token}", "dpop": f"{dpop_proof1}, {dpop_proof2}"}, # Multiple proofs + http_method="GET", + http_url="https://api.example.com/resource" + ) + + assert "multiple" in str(err.value).lower() or "single" in str(err.value).lower() \ No newline at end of file From a7a1b810a1cce537c3d1e5501a12b18baf897f97 Mon Sep 17 00:00:00 2001 From: Snehil Kishore Date: Tue, 29 Jul 2025 00:11:29 +0530 Subject: [PATCH 02/23] docs: add early access note for DPoP authentication feature --- packages/auth0_api_python/README.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/packages/auth0_api_python/README.md b/packages/auth0_api_python/README.md index a69c631..4cf05ab 100644 --- a/packages/auth0_api_python/README.md +++ b/packages/auth0_api_python/README.md @@ -100,6 +100,9 @@ If the token lacks `my_custom_claim` or fails any standard check (issuer mismatc ### 4. DPoP Authentication +> [!NOTE] +> This feature is currently available in [Early Access](https://auth0.com/docs/troubleshoot/product-lifecycle/product-release-stages#early-access). Please reach out to Auth0 support to get it enabled for your tenant. + This library supports **DPoP (Demonstrating Proof-of-Possession)** for enhanced security, allowing clients to prove possession of private keys bound to access tokens. #### Allowed Mode (Default) From 8f374116cba14a0d54241565319f34311d1dcc46 Mon Sep 17 00:00:00 2001 From: Snehil Kishore Date: Tue, 29 Jul 2025 00:26:54 +0530 Subject: [PATCH 03/23] ci: add GitHub Actions workflow for testing auth0-api-python package --- .github/workflows/test-auth0-api-python.yml | 58 +++++++++++++++++++++ 1 file changed, 58 insertions(+) create mode 100644 .github/workflows/test-auth0-api-python.yml diff --git a/.github/workflows/test-auth0-api-python.yml b/.github/workflows/test-auth0-api-python.yml new file mode 100644 index 0000000..2d7031c --- /dev/null +++ b/.github/workflows/test-auth0-api-python.yml @@ -0,0 +1,58 @@ +name: Test auth0-api-python + +on: + push: + branches: + - feature/auth0-api-python + paths: + - 'packages/auth0_api_python/**' + pull_request: + branches: + - main + paths: + - 'packages/auth0_api_python/**' + +jobs: + test: + runs-on: ubuntu-latest + strategy: + matrix: + python-version: [3.9, "3.10", "3.11", "3.12"] + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Set up Python ${{ matrix.python-version }} + uses: actions/setup-python@v4 + with: + python-version: ${{ matrix.python-version }} + + - name: Install Poetry + uses: snok/install-poetry@v1 + with: + version: latest + virtualenvs-create: true + virtualenvs-in-project: true + installer-parallel: true + + - name: Load cached venv + id: cached-poetry-dependencies + uses: actions/cache@v3 + with: + path: packages/auth0_api_python/.venv + key: venv-${{ runner.os }}-${{ matrix.python-version }}-${{ hashFiles('**/poetry.lock') }} + + - name: Install dependencies + if: steps.cached-poetry-dependencies.outputs.cache-hit != 'true' + working-directory: ./packages/auth0_api_python + run: poetry install --no-interaction --no-root + + - name: Install package + working-directory: ./packages/auth0_api_python + run: poetry install --no-interaction + + - name: Run tests with pytest + working-directory: ./packages/auth0_api_python + run: | + poetry run pytest -v --cov=src --cov-report=term-missing --cov-report=xml From 7d154e03c744525c2224b8b80d1656294470a138 Mon Sep 17 00:00:00 2001 From: Snehil Kishore Date: Tue, 29 Jul 2025 00:31:45 +0530 Subject: [PATCH 04/23] fix: update import paths to use package namespace instead of src directory --- packages/auth0_api_python/tests/test_api_client.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/auth0_api_python/tests/test_api_client.py b/packages/auth0_api_python/tests/test_api_client.py index 60a1beb..57e00a1 100644 --- a/packages/auth0_api_python/tests/test_api_client.py +++ b/packages/auth0_api_python/tests/test_api_client.py @@ -4,10 +4,10 @@ import time from pytest_httpx import HTTPXMock -from src.auth0_api_python.api_client import ApiClient -from src.auth0_api_python.config import ApiClientOptions -from src.auth0_api_python.errors import MissingRequiredArgumentError, VerifyAccessTokenError, InvalidDpopProofError, InvalidAuthSchemeError, MissingAuthorizationError -from src.auth0_api_python.token_utils import generate_token, generate_dpop_proof, generate_token_with_cnf, PRIVATE_JWK, PRIVATE_EC_JWK +from auth0_api_python.api_client import ApiClient +from auth0_api_python.config import ApiClientOptions +from auth0_api_python.errors import MissingRequiredArgumentError, VerifyAccessTokenError, InvalidDpopProofError, InvalidAuthSchemeError, MissingAuthorizationError +from auth0_api_python.token_utils import generate_token, generate_dpop_proof, generate_token_with_cnf, PRIVATE_JWK, PRIVATE_EC_JWK # Create public RSA JWK by excluding private key components PUBLIC_RSA_JWK = {k: v for k, v in PRIVATE_JWK.items() if k not in ["d", "p", "q", "dp", "dq", "qi"]} From 51e89874b14e70d51650f9e6b6a95452d0ff5433 Mon Sep 17 00:00:00 2001 From: Snehil Kishore Date: Tue, 29 Jul 2025 12:25:28 +0530 Subject: [PATCH 05/23] chore: add ruff linting and apply code style fixes --- .github/workflows/test-auth0-api-python.yml | 5 + packages/auth0_api_python/.ruff.toml | 16 ++ packages/auth0_api_python/poetry.lock | 73 ++++++--- packages/auth0_api_python/pyproject.toml | 1 + .../src/auth0_api_python/__init__.py | 2 +- .../src/auth0_api_python/api_client.py | 148 +++++++++--------- .../src/auth0_api_python/config.py | 3 +- .../src/auth0_api_python/errors.py | 46 +++--- .../src/auth0_api_python/token_utils.py | 49 +++--- .../src/auth0_api_python/utils.py | 46 +++--- .../auth0_api_python/tests/test_api_client.py | 74 +++++---- 11 files changed, 266 insertions(+), 197 deletions(-) create mode 100644 packages/auth0_api_python/.ruff.toml diff --git a/.github/workflows/test-auth0-api-python.yml b/.github/workflows/test-auth0-api-python.yml index 2d7031c..1d1a1bc 100644 --- a/.github/workflows/test-auth0-api-python.yml +++ b/.github/workflows/test-auth0-api-python.yml @@ -56,3 +56,8 @@ jobs: working-directory: ./packages/auth0_api_python run: | poetry run pytest -v --cov=src --cov-report=term-missing --cov-report=xml + + - name: Run ruff linting + working-directory: ./packages/auth0_api_python + run: | + poetry run ruff check . diff --git a/packages/auth0_api_python/.ruff.toml b/packages/auth0_api_python/.ruff.toml new file mode 100644 index 0000000..b500d05 --- /dev/null +++ b/packages/auth0_api_python/.ruff.toml @@ -0,0 +1,16 @@ +line-length = 100 +target-version = "py39" +select = [ + "E", # pycodestyle errors + "W", # pycodestyle warnings + "F", # pyflakes + "I", # isort + "B", # flake8-bugbear + "C4", # flake8-comprehensions + "UP", # pyupgrade + "S", # bandit (security) +] +ignore = ["E501", "B904"] # Line too long (handled by black), Exception handling without from + +[per-file-ignores] +"tests/*" = ["S101", "S105", "S106"] # Allow assert and ignore hardcoded password warnings in test files diff --git a/packages/auth0_api_python/poetry.lock b/packages/auth0_api_python/poetry.lock index e68f15f..7566cf4 100644 --- a/packages/auth0_api_python/poetry.lock +++ b/packages/auth0_api_python/poetry.lock @@ -1,4 +1,4 @@ -# This file is automatically @generated by Poetry 2.0.1 and should not be changed by hand. +# This file is automatically @generated by Poetry 2.1.2 and should not be changed by hand. [[package]] name = "anyio" @@ -20,7 +20,7 @@ typing_extensions = {version = ">=4.5", markers = "python_version < \"3.13\""} [package.extras] doc = ["Sphinx (>=8.2,<9.0)", "packaging", "sphinx-autodoc-typehints (>=1.2.0)", "sphinx_rtd_theme"] -test = ["anyio[trio]", "blockbuster (>=1.5.23)", "coverage[toml] (>=7)", "exceptiongroup (>=1.2.0)", "hypothesis (>=4.0)", "psutil (>=5.9)", "pytest (>=7.0)", "trustme", "truststore (>=0.9.1)", "uvloop (>=0.21)"] +test = ["anyio[trio]", "blockbuster (>=1.5.23)", "coverage[toml] (>=7)", "exceptiongroup (>=1.2.0)", "hypothesis (>=4.0)", "psutil (>=5.9)", "pytest (>=7.0)", "trustme", "truststore (>=0.9.1) ; python_version >= \"3.10\"", "uvloop (>=0.21) ; platform_python_implementation == \"CPython\" and platform_system != \"Windows\" and python_version < \"3.14\""] trio = ["trio (>=0.26.1)"] [[package]] @@ -45,7 +45,7 @@ description = "Backport of CPython tarfile module" optional = false python-versions = ">=3.8" groups = ["dev"] -markers = "(platform_machine != \"ppc64le\" and platform_machine != \"s390x\") and python_version < \"3.12\"" +markers = "platform_machine != \"ppc64le\" and platform_machine != \"s390x\" and python_version < \"3.12\"" files = [ {file = "backports.tarfile-1.2.0-py3-none-any.whl", hash = "sha256:77e284d754527b01fb1e6fa8a1afe577858ebe4e9dad8919e34c862cb399bc34"}, {file = "backports_tarfile-1.2.0.tar.gz", hash = "sha256:d75e02c268746e1b8144c278978b6e98e85de6ad16f8e4b0844a154557eca991"}, @@ -143,7 +143,7 @@ files = [ {file = "cffi-1.17.1-cp39-cp39-win_amd64.whl", hash = "sha256:d016c76bdd850f3c626af19b0542c9677ba156e4ee4fccfdd7848803533ef662"}, {file = "cffi-1.17.1.tar.gz", hash = "sha256:1c39c6016c32bc48dd54561950ebd6836e1670f2ae46128f67cf49e789c52824"}, ] -markers = {main = "platform_python_implementation != \"PyPy\"", dev = "(platform_machine != \"ppc64le\" and platform_machine != \"s390x\") and sys_platform == \"linux\" and platform_python_implementation != \"PyPy\""} +markers = {main = "platform_python_implementation != \"PyPy\"", dev = "platform_machine != \"ppc64le\" and platform_machine != \"s390x\" and sys_platform == \"linux\" and platform_python_implementation != \"PyPy\""} [package.dependencies] pycparser = "*" @@ -340,7 +340,7 @@ files = [ tomli = {version = "*", optional = true, markers = "python_full_version <= \"3.11.0a6\" and extra == \"toml\""} [package.extras] -toml = ["tomli"] +toml = ["tomli ; python_full_version <= \"3.11.0a6\""] [[package]] name = "cryptography" @@ -378,7 +378,7 @@ files = [ {file = "cryptography-43.0.3-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:2ce6fae5bdad59577b44e4dfed356944fbf1d925269114c28be377692643b4ff"}, {file = "cryptography-43.0.3.tar.gz", hash = "sha256:315b9001266a492a6ff443b61238f956b214dbec9910a081ba5b6646a055a805"}, ] -markers = {dev = "(platform_machine != \"ppc64le\" and platform_machine != \"s390x\") and sys_platform == \"linux\""} +markers = {dev = "platform_machine != \"ppc64le\" and platform_machine != \"s390x\" and sys_platform == \"linux\""} [package.dependencies] cffi = {version = ">=1.12", markers = "platform_python_implementation != \"PyPy\""} @@ -474,7 +474,7 @@ httpcore = "==1.*" idna = "*" [package.extras] -brotli = ["brotli", "brotlicffi"] +brotli = ["brotli ; platform_python_implementation == \"CPython\"", "brotlicffi ; platform_python_implementation != \"CPython\""] cli = ["click (==8.*)", "pygments (==2.*)", "rich (>=10,<14)"] http2 = ["h2 (>=3,<5)"] socks = ["socksio (==1.*)"] @@ -522,7 +522,7 @@ description = "Read metadata from Python packages" optional = false python-versions = ">=3.9" groups = ["dev"] -markers = "(platform_machine != \"ppc64le\" and platform_machine != \"s390x\") and python_version < \"3.12\" or python_version < \"3.10\"" +markers = "platform_machine != \"ppc64le\" and platform_machine != \"s390x\" and python_version < \"3.12\" or python_version == \"3.9\"" files = [ {file = "importlib_metadata-8.6.1-py3-none-any.whl", hash = "sha256:02a89390c1e15fdfdc0d7c6b25cb3e62650d0494005c97d6f148bf5b9787525e"}, {file = "importlib_metadata-8.6.1.tar.gz", hash = "sha256:310b41d755445d74569f993ccfc22838295d9fe005425094fad953d7f15c8580"}, @@ -532,12 +532,12 @@ files = [ zipp = ">=3.20" [package.extras] -check = ["pytest-checkdocs (>=2.4)", "pytest-ruff (>=0.2.1)"] +check = ["pytest-checkdocs (>=2.4)", "pytest-ruff (>=0.2.1) ; sys_platform != \"cygwin\""] cover = ["pytest-cov"] doc = ["furo", "jaraco.packaging (>=9.3)", "jaraco.tidelift (>=1.4)", "rst.linker (>=1.9)", "sphinx (>=3.5)", "sphinx-lint"] enabler = ["pytest-enabler (>=2.2)"] perf = ["ipython"] -test = ["flufl.flake8", "importlib_resources (>=1.3)", "jaraco.test (>=5.4)", "packaging", "pyfakefs", "pytest (>=6,!=8.1.*)", "pytest-perf (>=0.9.2)"] +test = ["flufl.flake8", "importlib_resources (>=1.3) ; python_version < \"3.9\"", "jaraco.test (>=5.4)", "packaging", "pyfakefs", "pytest (>=6,!=8.1.*)", "pytest-perf (>=0.9.2)"] type = ["pytest-mypy"] [[package]] @@ -590,7 +590,7 @@ files = [ [package.extras] doc = ["furo", "jaraco.packaging (>=9.3)", "jaraco.tidelift (>=1.4)", "rst.linker (>=1.9)", "sphinx (>=3.5)", "sphinx-lint"] -test = ["portend", "pytest (>=6,!=8.1.*)", "pytest-checkdocs (>=2.4)", "pytest-cov", "pytest-enabler (>=2.2)", "pytest-mypy", "pytest-ruff (>=0.2.1)"] +test = ["portend", "pytest (>=6,!=8.1.*)", "pytest-checkdocs (>=2.4)", "pytest-cov", "pytest-enabler (>=2.2)", "pytest-mypy", "pytest-ruff (>=0.2.1) ; sys_platform != \"cygwin\""] [[package]] name = "jaraco-functools" @@ -609,7 +609,7 @@ files = [ more-itertools = "*" [package.extras] -check = ["pytest-checkdocs (>=2.4)", "pytest-ruff (>=0.2.1)"] +check = ["pytest-checkdocs (>=2.4)", "pytest-ruff (>=0.2.1) ; sys_platform != \"cygwin\""] cover = ["pytest-cov"] doc = ["furo", "jaraco.packaging (>=9.3)", "jaraco.tidelift (>=1.4)", "rst.linker (>=1.9)", "sphinx (>=3.5)", "sphinx-lint"] enabler = ["pytest-enabler (>=2.2)"] @@ -623,14 +623,14 @@ description = "Low-level, pure Python DBus protocol wrapper." optional = false python-versions = ">=3.7" groups = ["dev"] -markers = "(platform_machine != \"ppc64le\" and platform_machine != \"s390x\") and sys_platform == \"linux\"" +markers = "platform_machine != \"ppc64le\" and platform_machine != \"s390x\" and sys_platform == \"linux\"" files = [ {file = "jeepney-0.9.0-py3-none-any.whl", hash = "sha256:97e5714520c16fc0a45695e5365a2e11b81ea79bba796e26f9f1d178cb182683"}, {file = "jeepney-0.9.0.tar.gz", hash = "sha256:cf0e9e845622b81e4a28df94c40345400256ec608d0e55bb8a3feaa9163f5732"}, ] [package.extras] -test = ["async-timeout", "pytest", "pytest-asyncio (>=0.17)", "pytest-trio", "testpath", "trio"] +test = ["async-timeout ; python_version < \"3.11\"", "pytest", "pytest-asyncio (>=0.17)", "pytest-trio", "testpath", "trio"] trio = ["trio"] [[package]] @@ -656,7 +656,7 @@ pywin32-ctypes = {version = ">=0.2.0", markers = "sys_platform == \"win32\""} SecretStorage = {version = ">=3.2", markers = "sys_platform == \"linux\""} [package.extras] -check = ["pytest-checkdocs (>=2.4)", "pytest-ruff (>=0.2.1)"] +check = ["pytest-checkdocs (>=2.4)", "pytest-ruff (>=0.2.1) ; sys_platform != \"cygwin\""] completion = ["shtab (>=1.1.0)"] cover = ["pytest-cov"] doc = ["furo", "jaraco.packaging (>=9.3)", "jaraco.tidelift (>=1.4)", "rst.linker (>=1.9)", "sphinx (>=3.5)", "sphinx-lint"] @@ -787,7 +787,7 @@ files = [ {file = "pycparser-2.22-py3-none-any.whl", hash = "sha256:c3702b6d3dd8c7abc1afa565d7e63d53a1d0bd86cdc24edd75470f4de499cfcc"}, {file = "pycparser-2.22.tar.gz", hash = "sha256:491c8be9c040f5390f5bf44a5b07752bd07f56edf992381b05c701439eec10f6"}, ] -markers = {main = "platform_python_implementation != \"PyPy\"", dev = "(platform_machine != \"ppc64le\" and platform_machine != \"s390x\") and sys_platform == \"linux\" and platform_python_implementation != \"PyPy\""} +markers = {main = "platform_python_implementation != \"PyPy\"", dev = "platform_machine != \"ppc64le\" and platform_machine != \"s390x\" and sys_platform == \"linux\" and platform_python_implementation != \"PyPy\""} [[package]] name = "pygments" @@ -909,7 +909,7 @@ description = "A (partial) reimplementation of pywin32 using ctypes/cffi" optional = false python-versions = ">=3.6" groups = ["dev"] -markers = "(platform_machine != \"ppc64le\" and platform_machine != \"s390x\") and sys_platform == \"win32\"" +markers = "platform_machine != \"ppc64le\" and platform_machine != \"s390x\" and sys_platform == \"win32\"" files = [ {file = "pywin32-ctypes-0.2.3.tar.gz", hash = "sha256:d162dc04946d704503b2edc4d55f3dba5c1d539ead017afa00142c38b9885755"}, {file = "pywin32_ctypes-0.2.3-py3-none-any.whl", hash = "sha256:8a1513379d709975552d202d942d9837758905c8d01eb82b8bcc30918929e7b8"}, @@ -1007,6 +1007,33 @@ typing-extensions = {version = ">=4.0.0,<5.0", markers = "python_version < \"3.1 [package.extras] jupyter = ["ipywidgets (>=7.5.1,<9)"] +[[package]] +name = "ruff" +version = "0.1.15" +description = "An extremely fast Python linter and code formatter, written in Rust." +optional = false +python-versions = ">=3.7" +groups = ["dev"] +files = [ + {file = "ruff-0.1.15-py3-none-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:5fe8d54df166ecc24106db7dd6a68d44852d14eb0729ea4672bb4d96c320b7df"}, + {file = "ruff-0.1.15-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:6f0bfbb53c4b4de117ac4d6ddfd33aa5fc31beeaa21d23c45c6dd249faf9126f"}, + {file = "ruff-0.1.15-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e0d432aec35bfc0d800d4f70eba26e23a352386be3a6cf157083d18f6f5881c8"}, + {file = "ruff-0.1.15-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:9405fa9ac0e97f35aaddf185a1be194a589424b8713e3b97b762336ec79ff807"}, + {file = "ruff-0.1.15-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c66ec24fe36841636e814b8f90f572a8c0cb0e54d8b5c2d0e300d28a0d7bffec"}, + {file = "ruff-0.1.15-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:6f8ad828f01e8dd32cc58bc28375150171d198491fc901f6f98d2a39ba8e3ff5"}, + {file = "ruff-0.1.15-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:86811954eec63e9ea162af0ffa9f8d09088bab51b7438e8b6488b9401863c25e"}, + {file = "ruff-0.1.15-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:fd4025ac5e87d9b80e1f300207eb2fd099ff8200fa2320d7dc066a3f4622dc6b"}, + {file = "ruff-0.1.15-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b17b93c02cdb6aeb696effecea1095ac93f3884a49a554a9afa76bb125c114c1"}, + {file = "ruff-0.1.15-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:ddb87643be40f034e97e97f5bc2ef7ce39de20e34608f3f829db727a93fb82c5"}, + {file = "ruff-0.1.15-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:abf4822129ed3a5ce54383d5f0e964e7fef74a41e48eb1dfad404151efc130a2"}, + {file = "ruff-0.1.15-py3-none-musllinux_1_2_i686.whl", hash = "sha256:6c629cf64bacfd136c07c78ac10a54578ec9d1bd2a9d395efbee0935868bf852"}, + {file = "ruff-0.1.15-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:1bab866aafb53da39c2cadfb8e1c4550ac5340bb40300083eb8967ba25481447"}, + {file = "ruff-0.1.15-py3-none-win32.whl", hash = "sha256:2417e1cb6e2068389b07e6fa74c306b2810fe3ee3476d5b8a96616633f40d14f"}, + {file = "ruff-0.1.15-py3-none-win_amd64.whl", hash = "sha256:3837ac73d869efc4182d9036b1405ef4c73d9b1f88da2413875e34e0d6919587"}, + {file = "ruff-0.1.15-py3-none-win_arm64.whl", hash = "sha256:9a933dfb1c14ec7a33cceb1e49ec4a16b51ce3c20fd42663198746efc0427360"}, + {file = "ruff-0.1.15.tar.gz", hash = "sha256:f6dfa8c1b21c913c326919056c390966648b680966febcb796cc9d1aaab8564e"}, +] + [[package]] name = "secretstorage" version = "3.3.3" @@ -1014,7 +1041,7 @@ description = "Python bindings to FreeDesktop.org Secret Service API" optional = false python-versions = ">=3.6" groups = ["dev"] -markers = "(platform_machine != \"ppc64le\" and platform_machine != \"s390x\") and sys_platform == \"linux\"" +markers = "platform_machine != \"ppc64le\" and platform_machine != \"s390x\" and sys_platform == \"linux\"" files = [ {file = "SecretStorage-3.3.3-py3-none-any.whl", hash = "sha256:f356e6628222568e3af06f2eba8df495efa13b3b63081dafd4f7d9a7b7bc9f99"}, {file = "SecretStorage-3.3.3.tar.gz", hash = "sha256:2403533ef369eca6d2ba81718576c5e0f564d5cca1b58f73a8b23e7d4eeebd77"}, @@ -1132,7 +1159,7 @@ files = [ ] [package.extras] -brotli = ["brotli (>=1.0.9)", "brotlicffi (>=0.8.0)"] +brotli = ["brotli (>=1.0.9) ; platform_python_implementation == \"CPython\"", "brotlicffi (>=0.8.0) ; platform_python_implementation != \"CPython\""] h2 = ["h2 (>=4,<5)"] socks = ["pysocks (>=1.5.6,!=1.5.7,<2.0)"] zstd = ["zstandard (>=0.18.0)"] @@ -1144,21 +1171,21 @@ description = "Backport of pathlib-compatible object wrapper for zip files" optional = false python-versions = ">=3.9" groups = ["dev"] -markers = "(platform_machine != \"ppc64le\" and platform_machine != \"s390x\") and python_version < \"3.12\" or python_version < \"3.10\"" +markers = "platform_machine != \"ppc64le\" and platform_machine != \"s390x\" and python_version < \"3.12\" or python_version == \"3.9\"" files = [ {file = "zipp-3.21.0-py3-none-any.whl", hash = "sha256:ac1bbe05fd2991f160ebce24ffbac5f6d11d83dc90891255885223d42b3cd931"}, {file = "zipp-3.21.0.tar.gz", hash = "sha256:2c9958f6430a2040341a52eb608ed6dd93ef4392e02ffe219417c1b28b5dd1f4"}, ] [package.extras] -check = ["pytest-checkdocs (>=2.4)", "pytest-ruff (>=0.2.1)"] +check = ["pytest-checkdocs (>=2.4)", "pytest-ruff (>=0.2.1) ; sys_platform != \"cygwin\""] cover = ["pytest-cov"] doc = ["furo", "jaraco.packaging (>=9.3)", "jaraco.tidelift (>=1.4)", "rst.linker (>=1.9)", "sphinx (>=3.5)", "sphinx-lint"] enabler = ["pytest-enabler (>=2.2)"] -test = ["big-O", "importlib-resources", "jaraco.functools", "jaraco.itertools", "jaraco.test", "more-itertools", "pytest (>=6,!=8.1.*)", "pytest-ignore-flaky"] +test = ["big-O", "importlib-resources ; python_version < \"3.9\"", "jaraco.functools", "jaraco.itertools", "jaraco.test", "more-itertools", "pytest (>=6,!=8.1.*)", "pytest-ignore-flaky"] type = ["pytest-mypy"] [metadata] lock-version = "2.1" python-versions = "^3.9" -content-hash = "f520b72141154b1ab70c231fd79737388402228a6a98305dcb0d3c78cd069cdf" +content-hash = "22ef8fc792ce494e591794d571c9dbb717920e7188d616e4c2e46c9863465cbb" diff --git a/packages/auth0_api_python/pyproject.toml b/packages/auth0_api_python/pyproject.toml index b6d6fe0..8fe5493 100644 --- a/packages/auth0_api_python/pyproject.toml +++ b/packages/auth0_api_python/pyproject.toml @@ -23,6 +23,7 @@ pytest-asyncio = "^0.20.3" pytest-mock = "^3.14.0" pytest-httpx = "^0.35.0" twine = "^6.1.0" +ruff = "^0.1.0" [tool.pytest.ini_options] addopts = "--cov=src --cov-report=term-missing:skip-covered --cov-report=xml" diff --git a/packages/auth0_api_python/src/auth0_api_python/__init__.py b/packages/auth0_api_python/src/auth0_api_python/__init__.py index a9b98fd..f487dd8 100644 --- a/packages/auth0_api_python/src/auth0_api_python/__init__.py +++ b/packages/auth0_api_python/src/auth0_api_python/__init__.py @@ -11,4 +11,4 @@ __all__ = [ "ApiClient", "ApiClientOptions" -] \ No newline at end of file +] diff --git a/packages/auth0_api_python/src/auth0_api_python/api_client.py b/packages/auth0_api_python/src/auth0_api_python/api_client.py index 9123060..67ce394 100644 --- a/packages/auth0_api_python/src/auth0_api_python/api_client.py +++ b/packages/auth0_api_python/src/auth0_api_python/api_client.py @@ -1,19 +1,25 @@ import time -import hashlib -from typing import Optional, List, Dict, Any, Tuple +from typing import Any, Optional -from authlib.jose import JsonWebToken, JsonWebKey +from authlib.jose import JsonWebKey, JsonWebToken from .config import ApiClientOptions from .errors import ( - MissingRequiredArgumentError, - VerifyAccessTokenError, - InvalidAuthSchemeError, + BaseAuthError, + InvalidAuthSchemeError, InvalidDpopProofError, - MissingAuthorizationError, - BaseAuthError + MissingAuthorizationError, + MissingRequiredArgumentError, + VerifyAccessTokenError, +) +from .utils import ( + calculate_jwk_thumbprint, + fetch_jwks, + fetch_oidc_metadata, + get_unverified_header, + normalize_url_for_htu, + sha256_base64url, ) -from .utils import fetch_oidc_metadata, fetch_jwks, get_unverified_header, normalize_url_for_htu, sha256_base64url, calculate_jwk_thumbprint class ApiClient: @@ -29,21 +35,21 @@ def __init__(self, options: ApiClientOptions): raise MissingRequiredArgumentError("audience") self.options = options - self._metadata: Optional[Dict[str, Any]] = None - self._jwks_data: Optional[Dict[str, Any]] = None + self._metadata: Optional[dict[str, Any]] = None + self._jwks_data: Optional[dict[str, Any]] = None self._jwt = JsonWebToken(["RS256"]) self._dpop_algorithms = ["ES256"] self._dpop_jwt = JsonWebToken(self._dpop_algorithms) - + async def verify_request( self, - headers: Dict[str, str], + headers: dict[str, str], http_method: Optional[str] = None, http_url: Optional[str] = None - ) -> Dict[str, Any]: + ) -> dict[str, Any]: """ Dispatch based on Authorization scheme: • If scheme is 'DPoP', verifies both access token and DPoP proof @@ -75,20 +81,20 @@ async def verify_request( ) else : raise self._prepare_error(MissingAuthorizationError()) - - + + parts = authorization_header.split(" ", 1) if len(parts) < 2: raise self._prepare_error(MissingAuthorizationError()) - - + + try: scheme, token = authorization_header.split(" ", 1) except ValueError: raise self._prepare_error( MissingAuthorizationError() ) - + scheme = scheme.strip().lower() @@ -110,7 +116,7 @@ async def verify_request( if scheme == "dpop": if not self.options.dpop_enabled: raise self._prepare_error(MissingAuthorizationError()) - + if not dpop_proof: if getattr(self.options, "dpop_required", False): raise self._prepare_error( @@ -119,10 +125,10 @@ async def verify_request( ) else: raise self._prepare_error( - InvalidDpopProofError("Operation indicated DPoP use but the request has no DPoP HTTP Header"), + InvalidDpopProofError("Operation indicated DPoP use but the request has no DPoP HTTP Header"), auth_scheme=scheme ) - + if "," in dpop_proof: raise self._prepare_error( InvalidDpopProofError("Multiple DPoP proofs are not allowed"), @@ -133,25 +139,25 @@ async def verify_request( await get_unverified_header(dpop_proof) except Exception: raise self._prepare_error(InvalidDpopProofError("Failed to verify DPoP proof"), auth_scheme=scheme) - + if not http_method or not http_url: raise self._prepare_error( InvalidDpopProofError("Operation indicated DPoP use but the request has no http_method or http_url"), auth_scheme=scheme ) - + try: access_token_claims = await self.verify_access_token(token) except VerifyAccessTokenError as e: raise self._prepare_error(e, auth_scheme=scheme) - + cnf_claim = access_token_claims.get("cnf") - + if not cnf_claim: raise self._prepare_error( InvalidDpopProofError("Operation indicated DPoP use but the JWT Access Token has no jkt confirmation claim"), auth_scheme=scheme ) - + if not isinstance(cnf_claim, dict): raise self._prepare_error( InvalidDpopProofError("Operation indicated DPoP use but the JWT Access Token has invalid confirmation claim format"), @@ -177,16 +183,16 @@ async def verify_request( VerifyAccessTokenError("Access token 'cnf' claim missing 'jkt'"), auth_scheme=scheme ) - + if expected_jkt != actual_jkt: raise self._prepare_error( VerifyAccessTokenError("JWT Access Token confirmation mismatch"), auth_scheme=scheme ) - + return access_token_claims - if scheme == "bearer": + if scheme == "bearer": if dpop_proof: if self.options.dpop_enabled: raise self._prepare_error( @@ -195,7 +201,7 @@ async def verify_request( ), auth_scheme=scheme ) - + try: claims = await self.verify_access_token(token) if claims.get("cnf") and claims["cnf"].get("jkt"): @@ -206,8 +212,8 @@ async def verify_request( ), auth_scheme=scheme ) - - + + return claims except VerifyAccessTokenError as e: raise self._prepare_error(e, auth_scheme=scheme) @@ -217,11 +223,11 @@ async def verify_request( async def verify_access_token( self, access_token: str, - required_claims: Optional[List[str]] = None - ) -> Dict[str, Any]: + required_claims: Optional[list[str]] = None + ) -> dict[str, Any]: """ Asynchronously verifies the provided JWT access token. - + - Fetches OIDC metadata and JWKS if not already cached. - Decodes and validates signature (RS256) with the correct key. - Checks standard claims: 'iss', 'aud', 'exp', 'iat' @@ -269,7 +275,7 @@ async def verify_access_token( if claims.get("iss") != issuer: raise VerifyAccessTokenError("Issuer mismatch") - + expected_aud = self.options.audience actual_aud = claims.get("aud") @@ -299,7 +305,7 @@ async def verify_dpop_proof( proof: str, http_method: str, http_url: str - ) -> Dict[str, Any]: + ) -> dict[str, Any]: """ 1. Single well-formed compact JWS 2. typ="dpop+jwt", alg∈allowed, alg≠none @@ -316,10 +322,10 @@ async def verify_dpop_proof( raise MissingRequiredArgumentError("http_method/http_url") header = await get_unverified_header(proof) - + if header.get("typ") != "dpop+jwt": raise InvalidDpopProofError("Unexpected JWT 'typ' header parameter value") - + alg = header.get("alg") if alg not in self._dpop_algorithms: raise InvalidDpopProofError(f"Unsupported alg: {alg}") @@ -327,13 +333,13 @@ async def verify_dpop_proof( jwk_dict = header.get("jwk") if not jwk_dict or not isinstance(jwk_dict, dict): raise InvalidDpopProofError("Missing or invalid jwk in header") - + if "d" in jwk_dict: raise InvalidDpopProofError("Private key material found in jwk header") - + if jwk_dict.get("kty") != "EC": raise InvalidDpopProofError("Only EC keys are supported for DPoP") - + if jwk_dict.get("crv") != "P-256": raise InvalidDpopProofError("Only P-256 curve is supported") @@ -347,16 +353,16 @@ async def verify_dpop_proof( self._validate_claims_presence(claims, ["iat", "ath", "htm", "htu", "jti"]) jti = claims["jti"] - + if not isinstance(jti, str): raise InvalidDpopProofError("jti claim must be a string") - + if not jti.strip(): raise InvalidDpopProofError("jti claim must not be empty") - + now = int(time.time()) - iat = claims["iat"] + iat = claims["iat"] offset = getattr(self.options, "dpop_iat_offset", 300) # default 5 minutes leeway = getattr(self.options, "dpop_iat_leeway", 30) # default 30 seconds @@ -368,18 +374,18 @@ async def verify_dpop_proof( if claims["htm"] != http_method: raise InvalidDpopProofError("DPoP Proof htm mismatch") - + if normalize_url_for_htu(claims["htu"]) != normalize_url_for_htu(http_url): raise InvalidDpopProofError("DPoP Proof htu mismatch") if claims["ath"] != sha256_base64url(access_token): raise InvalidDpopProofError("DPoP Proof ath mismatch") - + return claims # ===== Private Methods ===== - async def _discover(self) -> Dict[str, Any]: + async def _discover(self) -> dict[str, Any]: """Lazy-load OIDC discovery metadata.""" if self._metadata is None: self._metadata = await fetch_oidc_metadata( @@ -388,88 +394,88 @@ async def _discover(self) -> Dict[str, Any]: ) return self._metadata - async def _load_jwks(self) -> Dict[str, Any]: + async def _load_jwks(self) -> dict[str, Any]: """Fetches and caches JWKS data from the OIDC metadata.""" if self._jwks_data is None: metadata = await self._discover() jwks_uri = metadata["jwks_uri"] self._jwks_data = await fetch_jwks( - jwks_uri=jwks_uri, + jwks_uri=jwks_uri, custom_fetch=self.options.custom_fetch ) return self._jwks_data def _validate_claims_presence( - self, - claims: Dict[str, Any], - required_claims: List[str] + self, + claims: dict[str, Any], + required_claims: list[str] ) -> None: """ Validates that all required claims are present in the claims dict. - + Args: claims: The claims dictionary to validate required_claims: List of claim names that must be present - + Raises: InvalidDpopProofError: If any required claim is missing """ missing_claims = [] - + for claim in required_claims: if claim not in claims: missing_claims.append(claim) - + if missing_claims: if len(missing_claims) == 1: error_message = f"Missing required claim: {missing_claims[0]}" else: error_message = f"Missing required claims: {', '.join(missing_claims)}" - + raise InvalidDpopProofError(error_message) def _prepare_error(self, error: BaseAuthError, auth_scheme: Optional[str] = None) -> BaseAuthError: """ Prepare an error with WWW-Authenticate headers based on error type and context. - + Args: error: The error to prepare auth_scheme: The authentication scheme that was used ("bearer" or "dpop") """ error_code = error.get_error_code() error_description = error.get_error_description() - + www_auth_headers = self._build_www_authenticate( error_code=error_code if error_code != "unauthorized" else None, error_description=error_description if error_code != "unauthorized" else None, auth_scheme=auth_scheme ) - + headers = {} www_auth_values = [] for header_name, header_value in www_auth_headers: if header_name == "WWW-Authenticate": www_auth_values.append(header_value) - + if www_auth_values: headers["WWW-Authenticate"] = ", ".join(www_auth_values) - + error._headers = headers - + return error - + def _build_www_authenticate( self, *, error_code: Optional[str] = None, error_description: Optional[str] = None, auth_scheme: Optional[str] = None - ) -> List[Tuple[str, str]]: + ) -> list[tuple[str, str]]: """ Returns one or two ('WWW-Authenticate', ...) tuples based on context. If dpop_required mode → single DPoP challenge (with optional error params). Otherwise → Bearer and/or DPoP challenges based on auth_scheme and error. - + Args: error_code: Error code (e.g., "invalid_token", "invalid_request") error_description: Error description if any @@ -484,7 +490,7 @@ def _build_www_authenticate( bearer_parts.append(f'error_description="{error_description}"') return [("WWW-Authenticate", "Bearer " + ", ".join(bearer_parts))] return [("WWW-Authenticate", "Bearer")] - + algs = " ".join(self._dpop_algorithms) dpop_required = getattr(self.options, "dpop_required", False) @@ -534,4 +540,4 @@ def _build_www_authenticate( return [ ("WWW-Authenticate", "Bearer"), ("WWW-Authenticate", f'DPoP algs="{algs}"'), - ] \ No newline at end of file + ] diff --git a/packages/auth0_api_python/src/auth0_api_python/config.py b/packages/auth0_api_python/src/auth0_api_python/config.py index b7cb2ca..0cd555a 100644 --- a/packages/auth0_api_python/src/auth0_api_python/config.py +++ b/packages/auth0_api_python/src/auth0_api_python/config.py @@ -2,7 +2,8 @@ Configuration classes and utilities for auth0-api-python. """ -from typing import Optional, Callable +from typing import Callable, Optional + class ApiClientOptions: """ diff --git a/packages/auth0_api_python/src/auth0_api_python/errors.py b/packages/auth0_api_python/src/auth0_api_python/errors.py index 64013a1..287e23f 100644 --- a/packages/auth0_api_python/src/auth0_api_python/errors.py +++ b/packages/auth0_api_python/src/auth0_api_python/errors.py @@ -1,35 +1,35 @@ """ Custom exceptions for auth0-api-python SDK with HTTP response metadata """ -from typing import Dict, Any, Optional +from typing import Any class BaseAuthError(Exception): """Base class for all auth errors with HTTP response metadata.""" - + def __init__(self, message: str): super().__init__(message) self.message = message self.name = self.__class__.__name__ self._headers = {} # Will be set by ApiClient._prepare_error - + def get_status_code(self) -> int: """Return the HTTP status code for this error.""" raise NotImplementedError("Subclasses must implement get_status_code()") - + def get_error_code(self) -> str: """Return the OAuth/DPoP error code.""" raise NotImplementedError("Subclasses must implement get_error_code()") - + def get_error_description(self) -> str: """Return the error description.""" return self.message - - def get_headers(self) -> Dict[str, str]: + + def get_headers(self) -> dict[str, str]: """Return HTTP headers (including WWW-Authenticate if set).""" return self._headers - - def to_response_dict(self) -> Dict[str, Any]: + + def to_response_dict(self) -> dict[str, Any]: """Convert to a dictionary suitable for JSON response body.""" return { "error": self.get_error_code(), @@ -39,67 +39,67 @@ def to_response_dict(self) -> Dict[str, Any]: class MissingRequiredArgumentError(BaseAuthError): """Error raised when a required argument is missing.""" - + def __init__(self, argument: str): super().__init__(f"The argument '{argument}' is required but was not provided.") self.argument = argument - + def get_status_code(self) -> int: return 400 - + def get_error_code(self) -> str: return "invalid_request" class VerifyAccessTokenError(BaseAuthError): """Error raised when verifying the access token fails.""" - + def get_status_code(self) -> int: return 401 - + def get_error_code(self) -> str: return "invalid_token" class InvalidAuthSchemeError(BaseAuthError): """Error raised when the provided authentication scheme is unsupported.""" - + def __init__(self, message: str): super().__init__(message) if ":" in message and "'" in message: self.scheme = message.split("'")[1] else: self.scheme = None - + def get_status_code(self) -> int: return 400 - + def get_error_code(self) -> str: return "invalid_request" class InvalidDpopProofError(BaseAuthError): """Error raised when validating a DPoP proof fails.""" - + def get_status_code(self) -> int: return 400 - + def get_error_code(self) -> str: return "invalid_dpop_proof" class MissingAuthorizationError(BaseAuthError): """Authorization header is missing, empty, or malformed.""" - + def __init__(self): super().__init__("") - + def get_status_code(self) -> int: return 401 - + def get_error_code(self) -> str: return "" - + def get_error_description(self) -> str: return "" diff --git a/packages/auth0_api_python/src/auth0_api_python/token_utils.py b/packages/auth0_api_python/src/auth0_api_python/token_utils.py index f528a7e..755a38a 100644 --- a/packages/auth0_api_python/src/auth0_api_python/token_utils.py +++ b/packages/auth0_api_python/src/auth0_api_python/token_utils.py @@ -1,9 +1,10 @@ import time -from typing import Optional, Dict, Any, Union -from authlib.jose import JsonWebKey, jwt import uuid -from .utils import sha256_base64url, normalize_url_for_htu, calculate_jwk_thumbprint +from typing import Any, Optional, Union + +from authlib.jose import JsonWebKey, jwt +from .utils import calculate_jwk_thumbprint, normalize_url_for_htu, sha256_base64url # A private RSA JWK for test usage. @@ -30,7 +31,7 @@ async def generate_token( issuer: Union[str, bool, None] = None, iat: bool = True, exp: bool = True, - claims: Optional[Dict[str, Any]] = None, + claims: Optional[dict[str, Any]] = None, expiration_time: int = 3600, ) -> str: """ @@ -103,8 +104,8 @@ async def generate_dpop_proof( http_url: str, jti: Optional[str] = None, iat: bool = True, - claims: Optional[Dict[str, Any]] = None, - header_overrides: Optional[Dict[str, Any]] = None, + claims: Optional[dict[str, Any]] = None, + header_overrides: Optional[dict[str, Any]] = None, iat_time: Optional[int] = None ) -> str: """ @@ -132,36 +133,36 @@ async def generate_dpop_proof( claims={"custom": "claim"} ) """ - - + + proof_claims = dict(claims or {}) - + if iat: proof_claims["iat"] = iat_time if iat_time is not None else int(time.time()) - + if jti is not None: proof_claims["jti"] = jti else: proof_claims["jti"] = str(uuid.uuid4()) - + proof_claims["htm"] = http_method proof_claims["htu"] = normalize_url_for_htu(http_url) proof_claims["ath"] = sha256_base64url(access_token) - - + + public_jwk = {k: v for k, v in PRIVATE_EC_JWK.items() if k != "d"} - - + + header = { "alg": "ES256", "typ": "dpop+jwt", "jwk": public_jwk } - - + + if header_overrides: header.update(header_overrides) - + key = JsonWebKey.import_key(PRIVATE_EC_JWK) token = jwt.encode(header, proof_claims, key) # Ensure we return a string, not bytes @@ -197,19 +198,19 @@ async def generate_token_with_cnf( jkt_thumbprint="custom_thumbprint" ) """ - - + + if jkt_thumbprint is None: public_jwk = {k: v for k, v in PRIVATE_EC_JWK.items() if k != "d"} jkt_thumbprint = calculate_jwk_thumbprint(public_jwk) - - + + existing_claims = kwargs.get('claims', {}) cnf_claims = dict(existing_claims) cnf_claims["cnf"] = {"jkt": jkt_thumbprint} kwargs['claims'] = cnf_claims - - + + return await generate_token( domain=domain, user_id=user_id, diff --git a/packages/auth0_api_python/src/auth0_api_python/utils.py b/packages/auth0_api_python/src/auth0_api_python/utils.py index ef549f9..69245f7 100644 --- a/packages/auth0_api_python/src/auth0_api_python/utils.py +++ b/packages/auth0_api_python/src/auth0_api_python/utils.py @@ -1,21 +1,21 @@ """ -Utility functions for OIDC discovery and JWKS fetching (asynchronously) +Utility functions for OIDC discovery and JWKS fetching (asynchronously) using httpx or a custom fetch approach. """ -import httpx import base64 -import json import hashlib -import uuid -from typing import Any, Dict, Optional, Callable, Union - +import json +from typing import Any, Callable, Optional, Union from urllib.parse import urlparse, urlunparse +import httpx + + async def fetch_oidc_metadata( - domain: str, + domain: str, custom_fetch: Optional[Callable[..., Any]] = None -) -> Dict[str, Any]: +) -> dict[str, Any]: """ Asynchronously fetch the OIDC config from https://{domain}/.well-known/openid-configuration. Returns a dict with keys like issuer, jwks_uri, authorization_endpoint, etc. @@ -33,14 +33,14 @@ async def fetch_oidc_metadata( async def fetch_jwks( - jwks_uri: str, + jwks_uri: str, custom_fetch: Optional[Callable[..., Any]] = None -) -> Dict[str, Any]: +) -> dict[str, Any]: """ Asynchronously fetch the JSON Web Key Set from jwks_uri. Returns the raw JWKS JSON, e.g. {'keys': [...]} - If custom_fetch is provided, it must be an async callable + If custom_fetch is provided, it must be an async callable that fetches data from the jwks_uri. """ if custom_fetch: @@ -51,7 +51,7 @@ async def fetch_jwks( resp = await client.get(jwks_uri) resp.raise_for_status() return resp.json() - + async def get_unverified_header(token: Union[str, bytes]) -> dict: """ @@ -62,9 +62,9 @@ async def get_unverified_header(token: Union[str, bytes]) -> dict: token = token.decode("utf-8") try: header_b64, _, _ = token.split(".", 2) - except ValueError: - raise ValueError("Not enough segments in token") - + except ValueError as e: + raise ValueError("Not enough segments in token") from e + header_b64 = remove_bytes_prefix(header_b64) header_b64 = fix_base64_padding(header_b64) @@ -76,7 +76,7 @@ async def get_unverified_header(token: Union[str, bytes]) -> dict: def fix_base64_padding(segment: str) -> str: """ - If `segment`'s length is not a multiple of 4, add '=' padding + If `segment`'s length is not a multiple of 4, add '=' padding so that base64.urlsafe_b64decode won't produce nonsense bytes. No extra '=' added if length is already a multiple of 4. """ @@ -97,22 +97,22 @@ def normalize_url_for_htu(raw_url: str) -> str: Matches the level of normalization that browsers typically do. """ p = urlparse(raw_url) - + # Lowercase scheme and netloc (host) scheme = p.scheme.lower() netloc = p.netloc.lower() - + # Remove default ports if scheme == "http" and netloc.endswith(":80"): netloc = netloc[:-3] elif scheme == "https" and netloc.endswith(":443"): netloc = netloc[:-4] - + # Ensure non-empty path for http(s) path = p.path if scheme in ("http", "https") and not path: path = "/" - + return urlunparse((scheme, netloc, path, "", "", "")) @@ -128,7 +128,7 @@ def sha256_base64url(input_str: Union[str, bytes]) -> str: b64 = base64.urlsafe_b64encode(digest).decode("utf-8") return b64.rstrip("=") -def calculate_jwk_thumbprint(jwk: Dict[str, str]) -> str: +def calculate_jwk_thumbprint(jwk: dict[str, str]) -> str: """ Compute the RFC 7638 JWK thumbprint for a public JWK. @@ -137,7 +137,7 @@ def calculate_jwk_thumbprint(jwk: Dict[str, str]) -> str: - Hashes with SHA-256 and returns base64url-encoded string without padding """ kty = jwk.get("kty") - + if kty == "EC": if not all(k in jwk for k in ["crv", "x", "y"]): raise ValueError("EC key missing required parameters") @@ -155,4 +155,4 @@ def calculate_jwk_thumbprint(jwk: Dict[str, str]) -> str: digest = hashlib.sha256(thumbprint_json.encode("utf-8")).digest() # Base64URL-encode the digest and remove padding - return base64.urlsafe_b64encode(digest).decode("utf-8").rstrip("=") \ No newline at end of file + return base64.urlsafe_b64encode(digest).decode("utf-8").rstrip("=") diff --git a/packages/auth0_api_python/tests/test_api_client.py b/packages/auth0_api_python/tests/test_api_client.py index 57e00a1..45a2e3a 100644 --- a/packages/auth0_api_python/tests/test_api_client.py +++ b/packages/auth0_api_python/tests/test_api_client.py @@ -1,13 +1,25 @@ -import pytest import base64 import json import time -from pytest_httpx import HTTPXMock +import pytest from auth0_api_python.api_client import ApiClient from auth0_api_python.config import ApiClientOptions -from auth0_api_python.errors import MissingRequiredArgumentError, VerifyAccessTokenError, InvalidDpopProofError, InvalidAuthSchemeError, MissingAuthorizationError -from auth0_api_python.token_utils import generate_token, generate_dpop_proof, generate_token_with_cnf, PRIVATE_JWK, PRIVATE_EC_JWK +from auth0_api_python.errors import ( + InvalidAuthSchemeError, + InvalidDpopProofError, + MissingAuthorizationError, + MissingRequiredArgumentError, + VerifyAccessTokenError, +) +from auth0_api_python.token_utils import ( + PRIVATE_EC_JWK, + PRIVATE_JWK, + generate_dpop_proof, + generate_token, + generate_token_with_cnf, +) +from pytest_httpx import HTTPXMock # Create public RSA JWK by excluding private key components PUBLIC_RSA_JWK = {k: v for k, v in PRIVATE_JWK.items() if k not in ["d", "p", "q", "dp", "dq", "qi"]} @@ -19,7 +31,7 @@ async def test_init_missing_args(): """ with pytest.raises(MissingRequiredArgumentError): _ = ApiClient(ApiClientOptions(domain="", audience="some_audience")) - + with pytest.raises(MissingRequiredArgumentError): _ = ApiClient(ApiClientOptions(domain="example.us.auth0.com", audience="")) @@ -27,7 +39,7 @@ async def test_init_missing_args(): @pytest.mark.asyncio async def test_verify_access_token_successfully(httpx_mock: HTTPXMock): """ - Test that a valid RS256 token with correct issuer, audience, iat, and exp + Test that a valid RS256 token with correct issuer, audience, iat, and exp is verified successfully by ApiClient. """ httpx_mock.add_response( @@ -561,27 +573,27 @@ async def test_verify_dpop_proof_fail_invalid_alg(): """ Test that a DPoP proof with unsupported algorithm fails verification. """ - - + + access_token = "test_token" - + # First generate a valid DPoP proof valid_proof = await generate_dpop_proof( access_token=access_token, http_method="GET", http_url="https://api.example.com/resource" ) - + # Manually craft an invalid proof by modifying the algorithm parts = valid_proof.split('.') header = json.loads(base64.urlsafe_b64decode(parts[0] + '==').decode('utf-8')) header['alg'] = 'RS256' # Invalid algorithm for DPoP (should be ES256) - + # Re-encode the header modified_header = base64.urlsafe_b64encode( json.dumps(header, separators=(',', ':')).encode('utf-8') ).decode('utf-8').rstrip('=') - + # Create invalid proof with modified header but same payload and signature invalid_proof = f"{modified_header}.{parts[1]}.{parts[2]}" @@ -661,11 +673,11 @@ async def test_verify_dpop_proof_fail_private_key_in_jwk(): """ Test that a DPoP proof with private key material in jwk fails verification. """ - + access_token = "test_token" # Include private key material (the 'd' parameter) invalid_jwk = dict(PRIVATE_EC_JWK) # This includes the 'd' parameter - + dpop_proof = await generate_dpop_proof( access_token=access_token, http_method="GET", @@ -754,11 +766,11 @@ async def test_verify_dpop_proof_iat_exact_boundary_conditions(): Test IAT timing validation at exact boundary conditions. """ access_token = "test_token" - + # Test with timestamp exactly at the leeway boundary (should pass) current_time = int(time.time()) boundary_time = current_time + 30 # Exactly at default leeway limit - + dpop_proof = await generate_dpop_proof( access_token=access_token, http_method="GET", @@ -818,7 +830,7 @@ async def test_verify_dpop_proof_iat_clock_skew_scenarios(): """ access_token = "test_token" current_time = int(time.time()) - + # Test within acceptable skew (should pass) dpop_proof = await generate_dpop_proof( access_token=access_token, @@ -876,7 +888,7 @@ async def test_verify_dpop_proof_jti_uniqueness_scenarios(): Test JTI uniqueness and replay protection scenarios. """ access_token = "test_token" - + # Generate DPoP proof with specific JTI using the jti parameter custom_jti = "unique-jti-12345" dpop_proof = await generate_dpop_proof( @@ -964,7 +976,7 @@ async def test_verify_dpop_proof_htu_url_normalization_case_sensitivity(): Test HTU URL normalization handles case sensitivity correctly. """ access_token = "test_token" - + # Test with different case in domain (should be normalized and pass) dpop_proof = await generate_dpop_proof( access_token=access_token, @@ -1016,7 +1028,7 @@ async def test_verify_dpop_proof_htu_query_parameters(): Query parameters are stripped during normalization, so different params should succeed. """ access_token = "test_token" - + # Test with query parameters (should be normalized) dpop_proof = await generate_dpop_proof( access_token=access_token, @@ -1045,7 +1057,7 @@ async def test_verify_dpop_proof_htu_port_numbers(): Default ports (443 for HTTPS, 80 for HTTP) are stripped during normalization. """ access_token = "test_token" - + # Test with explicit default port (should be normalized) dpop_proof = await generate_dpop_proof( access_token=access_token, @@ -1073,7 +1085,7 @@ async def test_verify_dpop_proof_htu_fragment_handling(): Test HTU URL validation ignores fragments. """ access_token = "test_token" - + # Test with fragment (should be ignored) dpop_proof = await generate_dpop_proof( access_token=access_token, @@ -1102,7 +1114,7 @@ async def test_verify_dpop_proof_fail_ath_mismatch(): """ access_token = "test_token" wrong_token = "wrong_token" - + dpop_proof = await generate_dpop_proof( access_token=wrong_token, # Generate proof for wrong token http_method="GET", @@ -1238,7 +1250,7 @@ async def test_verify_request_fail_dpop_required_mode(): api_client = ApiClient( ApiClientOptions( - domain="auth0.local", + domain="auth0.local", audience="my-audience", dpop_required=True # Require DPoP ) @@ -1285,7 +1297,7 @@ async def test_verify_request_fail_dpop_enabled_bearer_with_cnf_conflict(httpx_m api_client = ApiClient( ApiClientOptions( - domain="auth0.local", + domain="auth0.local", audience="my-audience", dpop_enabled=True # DPoP enabled ) @@ -1315,7 +1327,7 @@ async def test_verify_request_fail_dpop_disabled(): api_client = ApiClient( ApiClientOptions( - domain="auth0.local", + domain="auth0.local", audience="my-audience", dpop_enabled=False # DPoP disabled ) @@ -1341,7 +1353,7 @@ async def test_verify_request_fail_missing_authorization_header(): ApiClientOptions(domain="auth0.local", audience="my-audience") ) - with pytest.raises(MissingAuthorizationError) as err: + with pytest.raises(MissingAuthorizationError): await api_client.verify_request( headers={}, http_method="GET", @@ -1358,7 +1370,7 @@ async def test_verify_request_fail_malformed_authorization_header(): ApiClientOptions(domain="auth0.local", audience="my-audience") ) - with pytest.raises(MissingAuthorizationError) as err: + with pytest.raises(MissingAuthorizationError): await api_client.verify_request( headers={"authorization": "InvalidFormat"}, # Missing scheme and token http_method="GET", @@ -1375,9 +1387,9 @@ async def test_verify_request_fail_unsupported_scheme(): ApiClientOptions(domain="auth0.local", audience="my-audience") ) - with pytest.raises(MissingAuthorizationError) as err: + with pytest.raises(MissingAuthorizationError): await api_client.verify_request( - headers={"authorization": "Basic dXNlcjpwYXNz"}, + headers={"authorization": "Basic dXNlcjpwYXNz"}, http_method="GET", http_url="https://api.example.com/resource" ) @@ -1432,4 +1444,4 @@ async def test_verify_request_fail_multiple_dpop_proofs(): http_url="https://api.example.com/resource" ) - assert "multiple" in str(err.value).lower() or "single" in str(err.value).lower() \ No newline at end of file + assert "multiple" in str(err.value).lower() or "single" in str(err.value).lower() From cfa18cfdae3f0a4836bfc0cec13fcbaf5d79b1b7 Mon Sep 17 00:00:00 2001 From: Snehil Kishore Date: Tue, 29 Jul 2025 16:39:15 +0530 Subject: [PATCH 06/23] docs: add examples for bearer and DPoP token authentication --- packages/auth0_api_python/EXAMPLES.md | 160 ++++++++++++++++++++++++++ 1 file changed, 160 insertions(+) create mode 100644 packages/auth0_api_python/EXAMPLES.md diff --git a/packages/auth0_api_python/EXAMPLES.md b/packages/auth0_api_python/EXAMPLES.md new file mode 100644 index 0000000..b361dca --- /dev/null +++ b/packages/auth0_api_python/EXAMPLES.md @@ -0,0 +1,160 @@ +# Auth0 API Python Examples + +This document provides examples for using the `auth0-api-python` package to validate Auth0 tokens in your API. + +## Bearer Authentication + +Bearer authentication is the standard OAuth 2.0 token authentication method. + +### Using verify_access_token + +```python +import asyncio +from auth0_api_python import ApiClient, ApiClientOptions + +async def validate_bearer_token(headers): + api_client = ApiClient(ApiClientOptions( + domain="your-tenant.auth0.com", + audience="https://api.example.com" + )) + + try: + # Extract the token from the Authorization header + auth_header = headers.get("authorization", "") + if not auth_header.startswith("Bearer "): + return {"error": "Missing or invalid authorization header"}, 401 + + token = auth_header.split(" ")[1] + + # Verify the access token + claims = await api_client.verify_access_token(token) + return {"success": True, "user": claims["sub"]} + except Exception as e: + return {"error": str(e)}, getattr(e, "get_status_code", lambda: 401)() + +# Example usage +headers = {"authorization": "Bearer eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9..."} +result = asyncio.run(validate_bearer_token(headers)) +``` + +### Using verify_request + +```python +import asyncio +from auth0_api_python import ApiClient, ApiClientOptions +from auth0_api_python.errors import BaseAuthError + +async def validate_request(headers): + api_client = ApiClient(ApiClientOptions( + domain="your-tenant.auth0.com", + audience="https://api.example.com" + )) + + try: + # Verify the request with Bearer token + claims = await api_client.verify_request( + headers=headers + ) + return {"success": True, "user": claims["sub"]} + except BaseAuthError as e: + return {"error": str(e)}, e.get_status_code(), e.get_headers() + +# Example usage +headers = {"authorization": "Bearer eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9..."} +result = asyncio.run(validate_request(headers)) +``` + + +## DPoP Authentication + +**DPoP (Demonstrating Proof-of-Possession)** is a security extension that binds access tokens to cryptographic keys, preventing token theft and replay attacks. + +This guide covers the DPoP implementation in `auth0-api-python` with complete examples for both operational modes. + +For more information about DPoP specification, see [RFC 9449](https://tools.ietf.org/html/rfc9449). + +## Configuration Modes + +### 1. Allowed Mode (Default) +```python +from auth0_api_python import ApiClient, ApiClientOptions + +api_client = ApiClient(ApiClientOptions( + domain="your-tenant.auth0.com", + audience="https://api.example.com", + dpop_enabled=True, # Default: enables DPoP support + dpop_required=False # Default: allows both Bearer and DPoP +)) +``` + +### 2. Required Mode +```python +api_client = ApiClient(ApiClientOptions( + domain="your-tenant.auth0.com", + audience="https://api.example.com", + dpop_required=True # Enforces DPoP-only authentication +)) +``` + +## Getting Started + +### Basic Usage with verify_request() + +The `verify_request()` method automatically detects the authentication scheme: + +```python +import asyncio +from auth0_api_python import ApiClient, ApiClientOptions + +async def handle_api_request(headers, http_method, http_url): + api_client = ApiClient(ApiClientOptions( + domain="your-tenant.auth0.com", + audience="https://api.example.com" + )) + + try: + # Automatically handles both Bearer and DPoP schemes + claims = await api_client.verify_request( + headers=headers, + http_method=http_method, + http_url=http_url + ) + return {"success": True, "user": claims["sub"]} + except Exception as e: + return {"error": str(e)}, e.get_status_code() + +# Example usage +headers = { + "authorization": "DPoP eyJ0eXAiOiJKV1Q...", + "dpop": "eyJ0eXAiOiJkcG9wK2p3dC..." +} +result = asyncio.run(handle_api_request(headers, "GET", "https://api.example.com/data")) +``` + +### Direct DPoP Proof Verification + +For more control, use `verify_dpop_proof()` directly: + +```python +async def verify_dpop_token(access_token, dpop_proof, http_method, http_url): + api_client = ApiClient(ApiClientOptions( + domain="your-tenant.auth0.com", + audience="https://api.example.com" + )) + + # First verify the access token + token_claims = await api_client.verify_access_token(access_token) + + # Then verify the DPoP proof + proof_claims = await api_client.verify_dpop_proof( + access_token=access_token, + proof=dpop_proof, + http_method=http_method, + http_url=http_url + ) + + return { + "token_claims": token_claims, + "proof_claims": proof_claims + } +``` \ No newline at end of file From be536e1dc58a7e2252e789b0324757b7caf64423 Mon Sep 17 00:00:00 2001 From: Snehil Kishore Date: Tue, 29 Jul 2025 16:41:48 +0530 Subject: [PATCH 07/23] docs: remove DPoP documentation link from README --- packages/auth0_api_python/README.md | 2 -- 1 file changed, 2 deletions(-) diff --git a/packages/auth0_api_python/README.md b/packages/auth0_api_python/README.md index 4cf05ab..5dfed63 100644 --- a/packages/auth0_api_python/README.md +++ b/packages/auth0_api_python/README.md @@ -153,8 +153,6 @@ api_client = ApiClient(ApiClientOptions( )) ``` -📖 **[Complete DPoP Documentation](docs/DPOP.md)** - Detailed guide with examples, error handling, and security considerations. - ## Feedback ### Contributing From 2452937234322ae4bb839e0b9257e56a0ad3c053 Mon Sep 17 00:00:00 2001 From: Snehil Kishore Date: Wed, 30 Jul 2025 21:33:29 +0530 Subject: [PATCH 08/23] feat: implement URL normalization using ada-url library and add test script --- packages/auth0_api_python/poetry.lock | 70 +++++++++++++++++- packages/auth0_api_python/pyproject.toml | 1 + packages/auth0_api_python/simple_url_test.py | 74 +++++++++++++++++++ .../src/auth0_api_python/api_client.py | 11 ++- .../src/auth0_api_python/errors.py | 8 -- .../src/auth0_api_python/utils.py | 33 +++------ .../auth0_api_python/tests/test_api_client.py | 14 ++-- 7 files changed, 166 insertions(+), 45 deletions(-) create mode 100644 packages/auth0_api_python/simple_url_test.py diff --git a/packages/auth0_api_python/poetry.lock b/packages/auth0_api_python/poetry.lock index 7566cf4..149605e 100644 --- a/packages/auth0_api_python/poetry.lock +++ b/packages/auth0_api_python/poetry.lock @@ -1,5 +1,69 @@ # This file is automatically @generated by Poetry 2.1.2 and should not be changed by hand. +[[package]] +name = "ada-url" +version = "1.25.0" +description = "URL parser and manipulator based on the WHAT WG URL standard" +optional = false +python-versions = ">=3.9" +groups = ["main"] +files = [ + {file = "ada_url-1.25.0-cp310-cp310-macosx_10_15_universal2.whl", hash = "sha256:745fa4448a796386f9330ecffad36c28ec319382ecae0337b97f2f91898dc6e6"}, + {file = "ada_url-1.25.0-cp310-cp310-macosx_10_15_x86_64.whl", hash = "sha256:7b5edc26b9dc4890696e002c5212de0370790e512609e63449cc536fbd88a38b"}, + {file = "ada_url-1.25.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:15e65f28fe7f779204a419598947037a574221998033620e7e22c0e5ccfb67fb"}, + {file = "ada_url-1.25.0-cp310-cp310-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:412b4708f65586fb775c5554d7bd4925d9dd5bc78a602cfa862db7a841c76b94"}, + {file = "ada_url-1.25.0-cp310-cp310-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:875b71e3ad468616260e8e83e1ef3d73edcc644a1d0ed0ec6e28b437a7c16e0f"}, + {file = "ada_url-1.25.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:0837369cad9e8b6eadafd7d2d074f04a41f485a33ef570c8064ff3582bede87a"}, + {file = "ada_url-1.25.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:9912262661c50729010cb8f0de78c069ab69a164e382b5cea0abe887038ee42f"}, + {file = "ada_url-1.25.0-cp310-cp310-win_amd64.whl", hash = "sha256:cc1abb2fb0e2de443d6d6f9746b8687fb535318397da9acd74496be6999bd7ab"}, + {file = "ada_url-1.25.0-cp311-cp311-macosx_10_15_universal2.whl", hash = "sha256:567b86a7c081632b445651fe8371f891699e658d1dac29162fef4984f89b21f0"}, + {file = "ada_url-1.25.0-cp311-cp311-macosx_10_15_x86_64.whl", hash = "sha256:6dc2d79ecfa24bc5b23e4a63b0d8cc1df2350729c51144da304d22210b077907"}, + {file = "ada_url-1.25.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:9ab2b3a0aee2a9737fa9d071a82ef9bb21cd5c2638a5621680632b8b2c22883b"}, + {file = "ada_url-1.25.0-cp311-cp311-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:646c240cccb65bcadf61b934b82e0e6c9bf1842b4b0c8492fc6921612761b7f4"}, + {file = "ada_url-1.25.0-cp311-cp311-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:727e060359188bb2f4f1c2e8b27e81ef5c778634df6910334d88e050430adbe9"}, + {file = "ada_url-1.25.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:98b7007d74294b0c10ded5500769c2adf8c1ffa584692510f7e990aeee2938f2"}, + {file = "ada_url-1.25.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:55bd83d820f7a987df7989695b0d964c16ded547d7e190c9dc9cf50c26160d00"}, + {file = "ada_url-1.25.0-cp311-cp311-win_amd64.whl", hash = "sha256:8133ccc849b14465b332c5f2ff3bbe692c9c0b4112f9e07f1efbdc690df822bf"}, + {file = "ada_url-1.25.0-cp312-cp312-macosx_10_15_universal2.whl", hash = "sha256:ce48f22981eefd50f526131034bb5cbab56034e8b367475c9b8098d9ba0489ff"}, + {file = "ada_url-1.25.0-cp312-cp312-macosx_10_15_x86_64.whl", hash = "sha256:97f502975a714aafe73b8d2c3b3ea3cbdeee3081ce619c37a5a1584ec1488234"}, + {file = "ada_url-1.25.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:244eefc2f7814dd25e40682812a7f36e7d1b16b7bdfa142abd397a954d20088f"}, + {file = "ada_url-1.25.0-cp312-cp312-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c1c4efeba2ec8fc5d9ab0195cd40d045f751967f192bab3d3685c8b9e95c5294"}, + {file = "ada_url-1.25.0-cp312-cp312-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:34bfa8a95892d10f12ac2203d66ff99f1bc7600fa5e4a0f1304902c037cb7fcc"}, + {file = "ada_url-1.25.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:9a232be74ed62c92463bf0f5b48914ab25b1e0ab6f88f7c65501063bde8bbbf5"}, + {file = "ada_url-1.25.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:29ae076213bc2f63b6a06e70c676e61e76708535e84b2eff97540907933d85a2"}, + {file = "ada_url-1.25.0-cp312-cp312-win_amd64.whl", hash = "sha256:867c023d220447811f5bd211b9d3957c8870ae963ee6f50b1781fbade2afaa84"}, + {file = "ada_url-1.25.0-cp313-cp313-macosx_10_15_universal2.whl", hash = "sha256:ba4c0856fa9edfbe347f5de390f81d1b230683718bbd9f88977acaabb5f9d53c"}, + {file = "ada_url-1.25.0-cp313-cp313-macosx_10_15_x86_64.whl", hash = "sha256:7e763188236de66e1b8762b69366f0cc92a0927b570d564a4cc27700be8783e3"}, + {file = "ada_url-1.25.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:c61e68df4f0a299dd851738fc1072b78a8551166dee049c989e3d088a53dc3f9"}, + {file = "ada_url-1.25.0-cp313-cp313-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0c6d07e378cc4ef6d3ddd46d7310932577cd93dd675334927bfb426688864c54"}, + {file = "ada_url-1.25.0-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e631331fc0bb4a032bb4bf437068945b5880597ee1a466b8c7a82d5af9c8d43d"}, + {file = "ada_url-1.25.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:4bba786e270af52a72869aa7407e2003a5c025a05ad4d736b7e4e35b0cda550b"}, + {file = "ada_url-1.25.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:a16eeb4993750f960e1f61693cf0c53d3949cc3a93eb0ee330e600fd1925c7b8"}, + {file = "ada_url-1.25.0-cp313-cp313-win_amd64.whl", hash = "sha256:558c6f1f040f08515bd1bbc75c31e3f94ee5922f39fa517d577b6e08a0b31885"}, + {file = "ada_url-1.25.0-cp39-cp39-macosx_10_15_universal2.whl", hash = "sha256:f36a894e6bb66108b3c2da6108c17dfa52f654c2d8c7129acde1f2c3e0c15684"}, + {file = "ada_url-1.25.0-cp39-cp39-macosx_10_15_x86_64.whl", hash = "sha256:50e84950e583aad377bf822b11e219b0f01a5f9e32e961171acea29563f292ce"}, + {file = "ada_url-1.25.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:626234902c7c886f322eef7d3b5adfd04f8e5c309c643e3ab6e27f7216c3fb13"}, + {file = "ada_url-1.25.0-cp39-cp39-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:dd85de9f31c882896e171f3f15124556cbe7d69de9047e174e2b5b2429365bc2"}, + {file = "ada_url-1.25.0-cp39-cp39-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:acd8547d288fa7e1a3d660833f9274d85aa2a5cd592c921efccfa821a21d591a"}, + {file = "ada_url-1.25.0-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:f28dc864f71ee4e97d5fa278cba2e8ee6406715d87ed1463bf175f9935f0a611"}, + {file = "ada_url-1.25.0-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:49a81fb368e65c5594d9895c02353deca2bb677206b31fcd4256cba1396329a9"}, + {file = "ada_url-1.25.0-cp39-cp39-win_amd64.whl", hash = "sha256:fcb63bea78099df0efc0339bcbeccb5b5b06031e8fe3689dfaa69f0463e8a61a"}, + {file = "ada_url-1.25.0-pp310-pypy310_pp73-macosx_10_15_x86_64.whl", hash = "sha256:a6bddee8bcf2d505ebce2d65bb192b845f907f97e8a22d7d36dea8ac79c5462d"}, + {file = "ada_url-1.25.0-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:92dda4112e681160b10d53dc6737ffbffba2fc4f8fded15d3d8f3e99e91e19c1"}, + {file = "ada_url-1.25.0-pp310-pypy310_pp73-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a3ad3a6e75ca109dda6d352b6a289e61d05a0fcf710956e5a338f4d8afe363bf"}, + {file = "ada_url-1.25.0-pp310-pypy310_pp73-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:847a86d2ba958cc0beadf85576aa3b00e97c952d67f54a9a712b06d6e86389d6"}, + {file = "ada_url-1.25.0-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:7e969821fec735fadfaa3949376baff5c199019306cb179eba146636610c43a6"}, + {file = "ada_url-1.25.0-pp39-pypy39_pp73-macosx_10_15_x86_64.whl", hash = "sha256:bdb801046f8384957f92706a74d0a2df4799b60d17b453441d91e730fc82c5fc"}, + {file = "ada_url-1.25.0-pp39-pypy39_pp73-macosx_11_0_arm64.whl", hash = "sha256:3f7d357a634c5258eb802fa1ab9c21fb74ed12486fb93ed9f7c89bf7addf4046"}, + {file = "ada_url-1.25.0-pp39-pypy39_pp73-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:85fb9d799ef9fc75d541721c7808b4177348f786512e707c094ea81255b304e3"}, + {file = "ada_url-1.25.0-pp39-pypy39_pp73-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a2c148e832588f252cae994470dc824c1840380ea69575769b1660c2d2663b1e"}, + {file = "ada_url-1.25.0-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:32fd7078a79813d27381c94726a440032ba225404a4ceace4c2431c388b23a00"}, + {file = "ada_url-1.25.0.tar.gz", hash = "sha256:d571c82a7d5b0965776b289de76319ed432bf85bc5e3d1dc624fb44cf50561be"}, +] + +[package.dependencies] +cffi = "*" + [[package]] name = "anyio" version = "4.9.0" @@ -143,7 +207,7 @@ files = [ {file = "cffi-1.17.1-cp39-cp39-win_amd64.whl", hash = "sha256:d016c76bdd850f3c626af19b0542c9677ba156e4ee4fccfdd7848803533ef662"}, {file = "cffi-1.17.1.tar.gz", hash = "sha256:1c39c6016c32bc48dd54561950ebd6836e1670f2ae46128f67cf49e789c52824"}, ] -markers = {main = "platform_python_implementation != \"PyPy\"", dev = "platform_machine != \"ppc64le\" and platform_machine != \"s390x\" and sys_platform == \"linux\" and platform_python_implementation != \"PyPy\""} +markers = {dev = "platform_machine != \"ppc64le\" and platform_machine != \"s390x\" and sys_platform == \"linux\" and platform_python_implementation != \"PyPy\""} [package.dependencies] pycparser = "*" @@ -787,7 +851,7 @@ files = [ {file = "pycparser-2.22-py3-none-any.whl", hash = "sha256:c3702b6d3dd8c7abc1afa565d7e63d53a1d0bd86cdc24edd75470f4de499cfcc"}, {file = "pycparser-2.22.tar.gz", hash = "sha256:491c8be9c040f5390f5bf44a5b07752bd07f56edf992381b05c701439eec10f6"}, ] -markers = {main = "platform_python_implementation != \"PyPy\"", dev = "platform_machine != \"ppc64le\" and platform_machine != \"s390x\" and sys_platform == \"linux\" and platform_python_implementation != \"PyPy\""} +markers = {dev = "platform_machine != \"ppc64le\" and platform_machine != \"s390x\" and sys_platform == \"linux\" and platform_python_implementation != \"PyPy\""} [[package]] name = "pygments" @@ -1188,4 +1252,4 @@ type = ["pytest-mypy"] [metadata] lock-version = "2.1" python-versions = "^3.9" -content-hash = "22ef8fc792ce494e591794d571c9dbb717920e7188d616e4c2e46c9863465cbb" +content-hash = "a31e9d335c52ee7f6daf3f436607275a5a496194d4377f503deb12fd2a75251c" diff --git a/packages/auth0_api_python/pyproject.toml b/packages/auth0_api_python/pyproject.toml index 8fe5493..dbc7eba 100644 --- a/packages/auth0_api_python/pyproject.toml +++ b/packages/auth0_api_python/pyproject.toml @@ -15,6 +15,7 @@ python = "^3.9" authlib = "^1.0" # For JWT/OIDC features requests = "^2.31.0" # If you use requests for HTTP calls (e.g., discovery) httpx = "^0.28.1" +ada-url = "^1.25.0" [tool.poetry.group.dev.dependencies] pytest = "^8.0" diff --git a/packages/auth0_api_python/simple_url_test.py b/packages/auth0_api_python/simple_url_test.py new file mode 100644 index 0000000..15120cd --- /dev/null +++ b/packages/auth0_api_python/simple_url_test.py @@ -0,0 +1,74 @@ +#!/usr/bin/env python3 +""" +Simple URL Normalization Test Script + +Usage: + python simple_url_test.py +""" + +import sys + +from auth0_api_python.utils import normalize_url_for_htu + +# Test cases covering different normalization aspects +TEST_CASES = [ + # Basic URL + "https://example.com/path", + + # Case normalization (scheme and host) + "HTTPS://EXAMPLE.COM/path", + + # Default port removal + "https://example.com:443/path", + + # Trailing slashes + "https://example.com/path/", + + # Percent-encoding normalization + "https://example.com/path%2fto%2fresource", + + # Path normalization + "https://example.com/path/../resource/./file.txt", + + # Query parameters and fragments + "https://example.com/path?query=value#fragment", + + # User info and case in path + "HTTPS://USER:PASS@EXAMPLE.COM:443/path/../RESOURCE/./file.txt?query=value#fragment", + + "https://example.com/path to my file", + + "https://example.com/path to %my+file", + + "https://example.com/path%20to%20%my+file" +] + +def process_url(url): + """Process a single URL and show the normalization result.""" + try: + normalized = normalize_url_for_htu(url) + print(f"Input: {url}") + print(f"Normalized: {normalized}") + print("-" * 50) + except Exception as e: + print(f"Input: {url}") + print(f"Error: {str(e)}") + print("-" * 50) + +def main(): + """Main function to run the test script.""" + print("URL Normalization Test") + print("=====================") + print() + + # Use command line arguments if provided, otherwise use default test cases + test_urls = sys.argv[1:] if len(sys.argv) > 1 else TEST_CASES + + for url in test_urls: + process_url(url) + + print("To test your own URLs, run:") + print(f"python {sys.argv[0]} \"https://example.com/your/path\" \"https://another.example.com/path\"") + +if __name__ == "__main__": + main() diff --git a/packages/auth0_api_python/src/auth0_api_python/api_client.py b/packages/auth0_api_python/src/auth0_api_python/api_client.py index 67ce394..cb2bbce 100644 --- a/packages/auth0_api_python/src/auth0_api_python/api_client.py +++ b/packages/auth0_api_python/src/auth0_api_python/api_client.py @@ -83,9 +83,14 @@ async def verify_request( raise self._prepare_error(MissingAuthorizationError()) - parts = authorization_header.split(" ", 1) - if len(parts) < 2: - raise self._prepare_error(MissingAuthorizationError()) + parts = authorization_header.split(" ") + if len(parts) != 2: + if len(parts) < 2: + raise self._prepare_error(MissingAuthorizationError()) + elif len(parts) > 2: + raise self._prepare_error( + InvalidAuthSchemeError("Invalid Authorization HTTP Header Format for authorization") + ) try: diff --git a/packages/auth0_api_python/src/auth0_api_python/errors.py b/packages/auth0_api_python/src/auth0_api_python/errors.py index 287e23f..9218b73 100644 --- a/packages/auth0_api_python/src/auth0_api_python/errors.py +++ b/packages/auth0_api_python/src/auth0_api_python/errors.py @@ -1,7 +1,6 @@ """ Custom exceptions for auth0-api-python SDK with HTTP response metadata """ -from typing import Any class BaseAuthError(Exception): @@ -29,13 +28,6 @@ def get_headers(self) -> dict[str, str]: """Return HTTP headers (including WWW-Authenticate if set).""" return self._headers - def to_response_dict(self) -> dict[str, Any]: - """Convert to a dictionary suitable for JSON response body.""" - return { - "error": self.get_error_code(), - "error_description": self.get_error_description() - } - class MissingRequiredArgumentError(BaseAuthError): """Error raised when a required argument is missing.""" diff --git a/packages/auth0_api_python/src/auth0_api_python/utils.py b/packages/auth0_api_python/src/auth0_api_python/utils.py index 69245f7..6120541 100644 --- a/packages/auth0_api_python/src/auth0_api_python/utils.py +++ b/packages/auth0_api_python/src/auth0_api_python/utils.py @@ -6,10 +6,11 @@ import base64 import hashlib import json +import re from typing import Any, Callable, Optional, Union -from urllib.parse import urlparse, urlunparse import httpx +from ada_url import URL async def fetch_oidc_metadata( @@ -93,28 +94,20 @@ def remove_bytes_prefix(s: str) -> str: def normalize_url_for_htu(raw_url: str) -> str: """ - Normalize URL for DPoP htu comparison following RFC 3986. - Matches the level of normalization that browsers typically do. + Normalize URL for DPoP htu comparison . """ - p = urlparse(raw_url) - # Lowercase scheme and netloc (host) - scheme = p.scheme.lower() - netloc = p.netloc.lower() + url_obj = URL(raw_url) - # Remove default ports - if scheme == "http" and netloc.endswith(":80"): - netloc = netloc[:-3] - elif scheme == "https" and netloc.endswith(":443"): - netloc = netloc[:-4] + normalized_url = url_obj.origin + url_obj.pathname - # Ensure non-empty path for http(s) - path = p.path - if scheme in ("http", "https") and not path: - path = "/" - - return urlunparse((scheme, netloc, path, "", "", "")) + normalized_url = re.sub( + r'%([0-9a-fA-F]{2})', + lambda m: f'%{m.group(1).upper()}', + normalized_url + ) + return normalized_url def sha256_base64url(input_str: Union[str, bytes]) -> str: """ @@ -145,14 +138,10 @@ def calculate_jwk_thumbprint(jwk: dict[str, str]) -> str: else: raise ValueError(f"{kty}(Key Type) Parameter missing or unsupported ") - # order the members and filter out any missing keys ordered = {k: jwk[k] for k in members if k in jwk} - # Serialize to JSON with no whitespace, sorted keys thumbprint_json = json.dumps(ordered, separators=(",", ":"), sort_keys=True) - #Using SHA-256 to hash the JSON string digest = hashlib.sha256(thumbprint_json.encode("utf-8")).digest() - # Base64URL-encode the digest and remove padding return base64.urlsafe_b64encode(digest).decode("utf-8").rstrip("=") diff --git a/packages/auth0_api_python/tests/test_api_client.py b/packages/auth0_api_python/tests/test_api_client.py index 45a2e3a..32fb789 100644 --- a/packages/auth0_api_python/tests/test_api_client.py +++ b/packages/auth0_api_python/tests/test_api_client.py @@ -407,7 +407,7 @@ async def test_verify_access_token_fail_no_audience_config(): -# DPOP PROOF VERIFICATION TESTS - Core Functionality & Validation +# DPOP PROOF VERIFICATION TESTS # --- Core Success Tests --- @@ -436,6 +436,7 @@ async def test_verify_dpop_proof_successfully(): ) assert claims["jti"] # Verify it has the required jti claim +# --- Header Validation Tests --- @pytest.mark.asyncio async def test_verify_dpop_proof_fail_no_access_token(): @@ -510,7 +511,7 @@ async def test_verify_dpop_proof_fail_no_http_method_url(): assert "http_method" in str(err.value).lower() or "http_url" in str(err.value).lower() -# --- Header Validation Tests --- +# --- Claim Validation Tests --- @pytest.mark.asyncio async def test_verify_dpop_proof_fail_no_typ(): @@ -573,18 +574,14 @@ async def test_verify_dpop_proof_fail_invalid_alg(): """ Test that a DPoP proof with unsupported algorithm fails verification. """ - - access_token = "test_token" - # First generate a valid DPoP proof valid_proof = await generate_dpop_proof( access_token=access_token, http_method="GET", http_url="https://api.example.com/resource" ) - - # Manually craft an invalid proof by modifying the algorithm + # Modify the proof to use an invalid algorithm parts = valid_proof.split('.') header = json.loads(base64.urlsafe_b64decode(parts[0] + '==').decode('utf-8')) header['alg'] = 'RS256' # Invalid algorithm for DPoP (should be ES256) @@ -799,7 +796,7 @@ async def test_verify_dpop_proof_iat_past_offset_boundary(): Test IAT validation with timestamps too far in the past. """ access_token = "test_token" - # Use a timestamp too far in the past (beyond acceptable skew) + # Use a timestamp too far in the past past_time = int(time.time()) - 3600 # 1 hour ago dpop_proof = await generate_dpop_proof( access_token=access_token, @@ -1340,7 +1337,6 @@ async def test_verify_request_fail_dpop_disabled(): http_url="https://api.example.com/resource" ) - # MissingAuthorizationError doesn't have a specific message for disabled DPoP assert isinstance(err.value, MissingAuthorizationError) From 8bff8fc58923da80a83737a6d6a72c9df5f44cea Mon Sep 17 00:00:00 2001 From: Snehil Kishore Date: Wed, 30 Jul 2025 22:12:17 +0530 Subject: [PATCH 09/23] chore: remove unused URL normalization test script --- packages/auth0_api_python/simple_url_test.py | 74 -------------------- 1 file changed, 74 deletions(-) delete mode 100644 packages/auth0_api_python/simple_url_test.py diff --git a/packages/auth0_api_python/simple_url_test.py b/packages/auth0_api_python/simple_url_test.py deleted file mode 100644 index 15120cd..0000000 --- a/packages/auth0_api_python/simple_url_test.py +++ /dev/null @@ -1,74 +0,0 @@ -#!/usr/bin/env python3 -""" -Simple URL Normalization Test Script - -Usage: - python simple_url_test.py -""" - -import sys - -from auth0_api_python.utils import normalize_url_for_htu - -# Test cases covering different normalization aspects -TEST_CASES = [ - # Basic URL - "https://example.com/path", - - # Case normalization (scheme and host) - "HTTPS://EXAMPLE.COM/path", - - # Default port removal - "https://example.com:443/path", - - # Trailing slashes - "https://example.com/path/", - - # Percent-encoding normalization - "https://example.com/path%2fto%2fresource", - - # Path normalization - "https://example.com/path/../resource/./file.txt", - - # Query parameters and fragments - "https://example.com/path?query=value#fragment", - - # User info and case in path - "HTTPS://USER:PASS@EXAMPLE.COM:443/path/../RESOURCE/./file.txt?query=value#fragment", - - "https://example.com/path to my file", - - "https://example.com/path to %my+file", - - "https://example.com/path%20to%20%my+file" -] - -def process_url(url): - """Process a single URL and show the normalization result.""" - try: - normalized = normalize_url_for_htu(url) - print(f"Input: {url}") - print(f"Normalized: {normalized}") - print("-" * 50) - except Exception as e: - print(f"Input: {url}") - print(f"Error: {str(e)}") - print("-" * 50) - -def main(): - """Main function to run the test script.""" - print("URL Normalization Test") - print("=====================") - print() - - # Use command line arguments if provided, otherwise use default test cases - test_urls = sys.argv[1:] if len(sys.argv) > 1 else TEST_CASES - - for url in test_urls: - process_url(url) - - print("To test your own URLs, run:") - print(f"python {sys.argv[0]} \"https://example.com/your/path\" \"https://another.example.com/path\"") - -if __name__ == "__main__": - main() From d8ef382e56deb2b515549c5d9d510b3131a67c11 Mon Sep 17 00:00:00 2001 From: Snehil Kishore Date: Wed, 30 Jul 2025 23:39:17 +0530 Subject: [PATCH 10/23] test: add validation tests for edge case --- .../auth0_api_python/tests/test_api_client.py | 205 ++++++++++++++---- 1 file changed, 159 insertions(+), 46 deletions(-) diff --git a/packages/auth0_api_python/tests/test_api_client.py b/packages/auth0_api_python/tests/test_api_client.py index 32fb789..e264c99 100644 --- a/packages/auth0_api_python/tests/test_api_client.py +++ b/packages/auth0_api_python/tests/test_api_client.py @@ -405,6 +405,20 @@ async def test_verify_access_token_fail_no_audience_config(): error_str = str(err.value).lower() assert "audience" in error_str and ("required" in error_str or "not provided" in error_str) +@pytest.mark.asyncio +async def test_verify_access_token_fail_malformed_token(): + """Test that a malformed token fails verification.""" + + api_client = ApiClient(ApiClientOptions(domain="auth0.local", audience="my-audience")) + + with pytest.raises(VerifyAccessTokenError) as e: + await api_client.verify_access_token("header.payload") + assert "failed to parse token" in str(e.value).lower() + + with pytest.raises(VerifyAccessTokenError) as e: + await api_client.verify_access_token("header.pay!load.signature") + assert "failed to parse token" in str(e.value).lower() + # DPOP PROOF VERIFICATION TESTS @@ -463,7 +477,6 @@ async def test_verify_dpop_proof_fail_no_access_token(): assert "access_token" in str(err.value).lower() - @pytest.mark.asyncio async def test_verify_dpop_proof_fail_no_dpop_proof(): """ @@ -483,7 +496,6 @@ async def test_verify_dpop_proof_fail_no_dpop_proof(): assert "dpop_proof" in str(err.value).lower() - @pytest.mark.asyncio async def test_verify_dpop_proof_fail_no_http_method_url(): """ @@ -538,8 +550,7 @@ async def test_verify_dpop_proof_fail_no_typ(): http_url="https://api.example.com/resource" ) - assert "typ" in str(err.value).lower() - + assert "unexpected jwt 'typ'" in str(err.value).lower() @pytest.mark.asyncio async def test_verify_dpop_proof_fail_invalid_typ(): @@ -566,8 +577,7 @@ async def test_verify_dpop_proof_fail_invalid_typ(): http_url="https://api.example.com/resource" ) - assert "typ" in str(err.value).lower() - + assert "unexpected jwt 'typ'" in str(err.value).lower() @pytest.mark.asyncio async def test_verify_dpop_proof_fail_invalid_alg(): @@ -581,17 +591,15 @@ async def test_verify_dpop_proof_fail_invalid_alg(): http_method="GET", http_url="https://api.example.com/resource" ) - # Modify the proof to use an invalid algorithm + parts = valid_proof.split('.') header = json.loads(base64.urlsafe_b64decode(parts[0] + '==').decode('utf-8')) header['alg'] = 'RS256' # Invalid algorithm for DPoP (should be ES256) - # Re-encode the header modified_header = base64.urlsafe_b64encode( json.dumps(header, separators=(',', ':')).encode('utf-8') ).decode('utf-8').rstrip('=') - # Create invalid proof with modified header but same payload and signature invalid_proof = f"{modified_header}.{parts[1]}.{parts[2]}" api_client = ApiClient( @@ -606,8 +614,7 @@ async def test_verify_dpop_proof_fail_invalid_alg(): http_url="https://api.example.com/resource" ) - assert "alg" in str(err.value).lower() - + assert "unsupported alg" in str(err.value).lower() @pytest.mark.asyncio async def test_verify_dpop_proof_fail_no_jwk(): @@ -634,8 +641,7 @@ async def test_verify_dpop_proof_fail_no_jwk(): http_url="https://api.example.com/resource" ) - assert "jwk" in str(err.value).lower() - + assert "missing or invalid jwk" in str(err.value).lower() @pytest.mark.asyncio async def test_verify_dpop_proof_fail_invalid_jwk_format(): @@ -662,8 +668,7 @@ async def test_verify_dpop_proof_fail_invalid_jwk_format(): http_url="https://api.example.com/resource" ) - assert "jwk" in str(err.value).lower() - + assert "missing or invalid jwk" in str(err.value).lower() @pytest.mark.asyncio async def test_verify_dpop_proof_fail_private_key_in_jwk(): @@ -696,7 +701,6 @@ async def test_verify_dpop_proof_fail_private_key_in_jwk(): assert "private key" in str(err.value).lower() - # --- IAT (Issued At Time) Validation Tests --- @pytest.mark.asyncio @@ -724,8 +728,7 @@ async def test_verify_dpop_proof_fail_no_iat(): http_url="https://api.example.com/resource" ) - assert "iat" in str(err.value).lower() - + assert "missing required claim" in str(err.value).lower() @pytest.mark.asyncio async def test_verify_dpop_proof_fail_invalid_iat_timing(): @@ -754,8 +757,7 @@ async def test_verify_dpop_proof_fail_invalid_iat_timing(): http_url="https://api.example.com/resource" ) - assert "iat" in str(err.value).lower() or "time" in str(err.value).lower() - + assert "iat is not recent enough" in str(err.value).lower() @pytest.mark.asyncio async def test_verify_dpop_proof_iat_exact_boundary_conditions(): @@ -789,7 +791,6 @@ async def test_verify_dpop_proof_iat_exact_boundary_conditions(): assert result is not None - @pytest.mark.asyncio async def test_verify_dpop_proof_iat_past_offset_boundary(): """ @@ -817,8 +818,7 @@ async def test_verify_dpop_proof_iat_past_offset_boundary(): http_url="https://api.example.com/resource" ) - assert "iat" in str(err.value).lower() or "time" in str(err.value).lower() - + assert "iat is not recent enough" in str(err.value).lower() @pytest.mark.asyncio async def test_verify_dpop_proof_iat_clock_skew_scenarios(): @@ -849,7 +849,6 @@ async def test_verify_dpop_proof_iat_clock_skew_scenarios(): ) assert result is not None - # --- JTI (JWT ID) Validation Tests --- @pytest.mark.asyncio @@ -877,7 +876,7 @@ async def test_verify_dpop_proof_fail_no_jti(): http_url="https://api.example.com/resource" ) - assert "jti" in str(err.value).lower() + assert "jti claim must not be empty" in str(err.value).lower() @pytest.mark.asyncio async def test_verify_dpop_proof_jti_uniqueness_scenarios(): @@ -886,7 +885,6 @@ async def test_verify_dpop_proof_jti_uniqueness_scenarios(): """ access_token = "test_token" - # Generate DPoP proof with specific JTI using the jti parameter custom_jti = "unique-jti-12345" dpop_proof = await generate_dpop_proof( access_token=access_token, @@ -910,7 +908,6 @@ async def test_verify_dpop_proof_jti_uniqueness_scenarios(): assert result is not None assert result["jti"] == custom_jti - @pytest.mark.asyncio async def test_verify_dpop_proof_fail_htm_mismatch(): """ @@ -935,8 +932,7 @@ async def test_verify_dpop_proof_fail_htm_mismatch(): http_url="https://api.example.com/resource" ) - assert "htm" in str(err.value).lower() or "method" in str(err.value).lower() - + assert "htm mismatch" in str(err.value).lower() # --- HTU (HTTP URI) Validation Tests --- @@ -964,8 +960,7 @@ async def test_verify_dpop_proof_fail_htu_mismatch(): http_url="https://api.example.com/resource" # But verify with correct URL ) - assert "htu" in str(err.value).lower() or "url" in str(err.value).lower() - + assert "htu mismatch" in str(err.value).lower() @pytest.mark.asyncio async def test_verify_dpop_proof_htu_url_normalization_case_sensitivity(): @@ -1008,15 +1003,14 @@ async def test_verify_dpop_proof_htu_trailing_slash_normalization(): http_url="https://api.example.com/resource/" ) api_client = ApiClient(ApiClientOptions(domain="auth0.local", audience="my-audience")) - with pytest.raises(InvalidDpopProofError): + with pytest.raises(InvalidDpopProofError) as err: await api_client.verify_dpop_proof( access_token=access_token, proof=dpop_proof, http_method="GET", http_url="https://api.example.com/resource" ) - - + assert "htu mismatch" in str(err.value).lower() @pytest.mark.asyncio async def test_verify_dpop_proof_htu_query_parameters(): @@ -1075,7 +1069,6 @@ async def test_verify_dpop_proof_htu_port_numbers(): ) assert result is not None - @pytest.mark.asyncio async def test_verify_dpop_proof_htu_fragment_handling(): """ @@ -1103,7 +1096,6 @@ async def test_verify_dpop_proof_htu_fragment_handling(): ) assert result is not None - @pytest.mark.asyncio async def test_verify_dpop_proof_fail_ath_mismatch(): """ @@ -1132,6 +1124,111 @@ async def test_verify_dpop_proof_fail_ath_mismatch(): assert "ath" in str(err.value).lower() or "hash" in str(err.value).lower() +@pytest.mark.asyncio +async def test_verify_dpop_proof_with_invalid_signature(): + """Test verify_dpop_proof with invalid signature.""" + access_token = "test_token" + + valid_proof = await generate_dpop_proof( + access_token=access_token, + http_method="GET", + http_url="https://api.example.com/resource" + ) + + parts = valid_proof.split('.') + if len(parts) == 3: + header, payload, signature = parts + tampered_proof = f"{header}.{payload}.{signature[:-5]}12345" + else: + tampered_proof = valid_proof + + api_client = ApiClient(ApiClientOptions(domain="auth0.local", audience="my-audience")) + with pytest.raises(InvalidDpopProofError) as e: + await api_client.verify_dpop_proof( + access_token=access_token, + proof=tampered_proof, + http_method="GET", + http_url="https://api.example.com/resource" + ) + assert "signature verification failed" in str(e.value).lower() + +@pytest.mark.asyncio +async def test_verify_dpop_proof_with_invalid_jwk_format(): + """Test verify_dpop_proof with invalid JWK format.""" + access_token = "test_token" + + dpop_proof = await generate_dpop_proof( + access_token=access_token, + http_method="GET", + http_url="https://api.example.com/resource", + header_overrides={"jwk": "not-a-valid-jwk-object"} + ) + + api_client = ApiClient(ApiClientOptions(domain="auth0.local", audience="my-audience")) + with pytest.raises(InvalidDpopProofError) as err: + await api_client.verify_dpop_proof( + access_token=access_token, + proof=dpop_proof, + http_method="GET", + http_url="https://api.example.com/resource" + ) + assert "jwk" in str(err.value).lower() + +@pytest.mark.asyncio +async def test_verify_dpop_proof_with_missing_jwk_parameters(): + """Test verify_dpop_proof with missing JWK parameters.""" + access_token = "test_token" + + incomplete_jwk = {"kty": "RSA"} + + dpop_proof = await generate_dpop_proof( + access_token=access_token, + http_method="GET", + http_url="https://api.example.com/resource", + header_overrides={"jwk": incomplete_jwk} + ) + + api_client = ApiClient(ApiClientOptions(domain="auth0.local", audience="my-audience")) + with pytest.raises(InvalidDpopProofError) as err: + await api_client.verify_dpop_proof( + access_token=access_token, + proof=dpop_proof, + http_method="GET", + http_url="https://api.example.com/resource" + ) + assert "only ec keys are supported" in str(err.value).lower() + +@pytest.mark.asyncio +async def test_verify_dpop_proof_with_missing_jti(): + """Test verify_dpop_proof with missing jti claim.""" + access_token = "test_token" + + dpop_proof = await generate_dpop_proof( + access_token=access_token, + http_method="GET", + http_url="https://api.example.com/resource", + jti=None, + claims={"jti": None} + ) + + parts = dpop_proof.split('.') + if len(parts) == 3: + header, payload, signature = parts + decoded_payload = json.loads(base64.urlsafe_b64decode(payload + '=' * (4 - len(payload) % 4)).decode('utf-8')) + del decoded_payload['jti'] + modified_payload = base64.urlsafe_b64encode(json.dumps(decoded_payload).encode('utf-8')).decode('utf-8').rstrip('=') + dpop_proof = f"{header}.{modified_payload}.{signature}" + + api_client = ApiClient(ApiClientOptions(domain="auth0.local", audience="my-audience")) + with pytest.raises(InvalidDpopProofError) as err: + await api_client.verify_dpop_proof( + access_token=access_token, + proof=dpop_proof, + http_method="GET", + http_url="https://api.example.com/resource" + ) + assert "signature verification failed" in str(err.value).lower() + # VERIFY_REQUEST TESTS # --- Success Tests --- @@ -1180,7 +1277,6 @@ async def test_verify_request_bearer_scheme_success(httpx_mock: HTTPXMock): assert result["aud"] == "my-audience" assert result["iss"] == "https://auth0.local/" - @pytest.mark.asyncio async def test_verify_request_dpop_scheme_success(httpx_mock: HTTPXMock): """ @@ -1262,7 +1358,6 @@ async def test_verify_request_fail_dpop_required_mode(): assert "dpop" in str(err.value).lower() or "bearer" in str(err.value).lower() - @pytest.mark.asyncio async def test_verify_request_fail_dpop_enabled_bearer_with_cnf_conflict(httpx_mock: HTTPXMock): """ @@ -1309,7 +1404,6 @@ async def test_verify_request_fail_dpop_enabled_bearer_with_cnf_conflict(httpx_m assert "cnf" in str(err.value).lower() or "dpop" in str(err.value).lower() - @pytest.mark.asyncio async def test_verify_request_fail_dpop_disabled(): """ @@ -1339,7 +1433,6 @@ async def test_verify_request_fail_dpop_disabled(): assert isinstance(err.value, MissingAuthorizationError) - @pytest.mark.asyncio async def test_verify_request_fail_missing_authorization_header(): """ @@ -1356,7 +1449,6 @@ async def test_verify_request_fail_missing_authorization_header(): http_url="https://api.example.com/resource" ) - @pytest.mark.asyncio async def test_verify_request_fail_malformed_authorization_header(): """ @@ -1373,7 +1465,6 @@ async def test_verify_request_fail_malformed_authorization_header(): http_url="https://api.example.com/resource" ) - @pytest.mark.asyncio async def test_verify_request_fail_unsupported_scheme(): """ @@ -1390,7 +1481,6 @@ async def test_verify_request_fail_unsupported_scheme(): http_url="https://api.example.com/resource" ) - @pytest.mark.asyncio async def test_verify_request_fail_missing_dpop_header(): """ @@ -1409,8 +1499,7 @@ async def test_verify_request_fail_missing_dpop_header(): http_url="https://api.example.com/resource" ) - assert "dpop" in str(err.value).lower() or "proof" in str(err.value).lower() - + assert "request has no dpop http header" in str(err.value).lower() @pytest.mark.asyncio async def test_verify_request_fail_multiple_dpop_proofs(): @@ -1440,4 +1529,28 @@ async def test_verify_request_fail_multiple_dpop_proofs(): http_url="https://api.example.com/resource" ) - assert "multiple" in str(err.value).lower() or "single" in str(err.value).lower() + assert "multiple" in str(err.value).lower() + +@pytest.mark.asyncio +async def test_verify_request_with_empty_token(): + """Test verify_request with empty token value.""" + api_client = ApiClient(ApiClientOptions(domain="auth0.local", audience="my-audience")) + with pytest.raises(MissingAuthorizationError): + await api_client.verify_request({"Authorization": "Bearer "}) + +@pytest.mark.asyncio +async def test_verify_request_with_multiple_spaces_in_authorization(): + """Test verify_request with authorization header containing multiple spaces.""" + api_client = ApiClient( + ApiClientOptions(domain="auth0.local", audience="my-audience") + ) + with pytest.raises(InvalidAuthSchemeError) as err: + await api_client.verify_request({"authorization": "Bearer token with extra spaces"}) + assert "authorization" in str(err.value).lower() + +@pytest.mark.asyncio +async def test_verify_request_with_mixed_case_authorization_header(): + """Test verify_request with mixed case authorization header.""" + api_client = ApiClient(ApiClientOptions(domain="auth0.local", audience="my-audience")) + with pytest.raises(MissingAuthorizationError): + await api_client.verify_request({"AuThOrIzAtIoN": "Bearer token"}) From c9014aa7c0fd0f2e34ed9a39185c8dcfbbc1da92 Mon Sep 17 00:00:00 2001 From: Snehil Kishore Date: Wed, 30 Jul 2025 23:41:34 +0530 Subject: [PATCH 11/23] test: verify error message for htu mismatch in dpop proof validation --- packages/auth0_api_python/tests/test_api_client.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/auth0_api_python/tests/test_api_client.py b/packages/auth0_api_python/tests/test_api_client.py index e264c99..645136a 100644 --- a/packages/auth0_api_python/tests/test_api_client.py +++ b/packages/auth0_api_python/tests/test_api_client.py @@ -1010,7 +1010,7 @@ async def test_verify_dpop_proof_htu_trailing_slash_normalization(): http_method="GET", http_url="https://api.example.com/resource" ) - assert "htu mismatch" in str(err.value).lower() + assert "htu mismatch" in str(err.value).lower() @pytest.mark.asyncio async def test_verify_dpop_proof_htu_query_parameters(): From 940c735e021e923c1c40ea4da9f65b26e80cacb2 Mon Sep 17 00:00:00 2001 From: Snehil Kishore Date: Thu, 31 Jul 2025 15:40:42 +0530 Subject: [PATCH 12/23] refactor: improve URL normalization and DPoP verification --- .../src/auth0_api_python/api_client.py | 55 ++++++++----------- .../src/auth0_api_python/utils.py | 14 ++--- 2 files changed, 31 insertions(+), 38 deletions(-) diff --git a/packages/auth0_api_python/src/auth0_api_python/api_client.py b/packages/auth0_api_python/src/auth0_api_python/api_client.py index cb2bbce..7e2a3bc 100644 --- a/packages/auth0_api_python/src/auth0_api_python/api_client.py +++ b/packages/auth0_api_python/src/auth0_api_python/api_client.py @@ -43,6 +43,10 @@ def __init__(self, options: ApiClientOptions): self._dpop_algorithms = ["ES256"] self._dpop_jwt = JsonWebToken(self._dpop_algorithms) + def is_dpop_required(self) -> bool: + """Check if DPoP authentication is required.""" + return getattr(self.options, "dpop_required", False) + async def verify_request( self, @@ -56,7 +60,7 @@ async def verify_request( • If scheme is 'Bearer', verifies only the access token Args: - headers: HTTP headers dict containing: + headers: HTTP headers dict containing (header keys should be lowercase): - "authorization": The Authorization header value (required) - "dpop": The DPoP proof header value (required for DPoP) http_method: The HTTP method (required for DPoP) @@ -75,7 +79,7 @@ async def verify_request( dpop_proof = headers.get("dpop") if not authorization_header: - if getattr(self.options, "dpop_required", False): + if self.is_dpop_required(): raise self._prepare_error( InvalidAuthSchemeError("Expecting Authorization header with DPoP scheme.") ) @@ -92,28 +96,17 @@ async def verify_request( InvalidAuthSchemeError("Invalid Authorization HTTP Header Format for authorization") ) - - try: - scheme, token = authorization_header.split(" ", 1) - except ValueError: - raise self._prepare_error( - MissingAuthorizationError() - ) - + scheme, token = parts scheme = scheme.strip().lower() - if getattr(self.options, "dpop_required", False) and scheme != "dpop": - if scheme == "bearer": - raise self._prepare_error( - InvalidAuthSchemeError("Invalid scheme. Expected 'DPoP', but got 'bearer'."), - auth_scheme=scheme - ) - else: - raise self._prepare_error( - InvalidAuthSchemeError("Invalid scheme. Expected 'DPoP' scheme."), - auth_scheme=scheme - ) + if self.is_dpop_required() and scheme != "dpop": + raise self._prepare_error( + InvalidAuthSchemeError( + f"Invalid scheme. Expected DPoP{', but got ' + scheme + '.' if scheme and scheme != 'dpop' else ' scheme.'}" + ), + auth_scheme=scheme + ) if not token.strip(): raise self._prepare_error(MissingAuthorizationError()) @@ -123,7 +116,7 @@ async def verify_request( raise self._prepare_error(MissingAuthorizationError()) if not dpop_proof: - if getattr(self.options, "dpop_required", False): + if self.is_dpop_required(): raise self._prepare_error( InvalidAuthSchemeError("Expecting Authorization header with DPoP scheme."), auth_scheme=scheme @@ -141,7 +134,7 @@ async def verify_request( ) try: - await get_unverified_header(dpop_proof) + dpop_header = get_unverified_header(dpop_proof) except Exception: raise self._prepare_error(InvalidDpopProofError("Failed to verify DPoP proof"), auth_scheme=scheme) @@ -179,7 +172,7 @@ async def verify_request( raise self._prepare_error(e, auth_scheme=scheme) # DPoP binding verification - jwk_dict = (await get_unverified_header(dpop_proof))["jwk"] + jwk_dict = dpop_header["jwk"] actual_jkt = calculate_jwk_thumbprint(jwk_dict) expected_jkt = cnf_claim.get("jkt") @@ -209,7 +202,7 @@ async def verify_request( try: claims = await self.verify_access_token(token) - if claims.get("cnf") and claims["cnf"].get("jkt"): + if claims.get("cnf") and isinstance(claims["cnf"], dict) and claims["cnf"].get("jkt"): if self.options.dpop_enabled: raise self._prepare_error( InvalidAuthSchemeError( @@ -251,7 +244,7 @@ async def verify_access_token( required_claims = required_claims or [] try: - header = await get_unverified_header(access_token) + header = get_unverified_header(access_token) kid = header["kid"] except Exception as e: raise VerifyAccessTokenError(f"Failed to parse token header: {str(e)}") from e @@ -326,7 +319,7 @@ async def verify_dpop_proof( if not http_method or not http_url: raise MissingRequiredArgumentError("http_method/http_url") - header = await get_unverified_header(proof) + header = get_unverified_header(proof) if header.get("typ") != "dpop+jwt": raise InvalidDpopProofError("Unexpected JWT 'typ' header parameter value") @@ -371,13 +364,13 @@ async def verify_dpop_proof( offset = getattr(self.options, "dpop_iat_offset", 300) # default 5 minutes leeway = getattr(self.options, "dpop_iat_leeway", 30) # default 30 seconds - if not isinstance(iat, int): - raise InvalidDpopProofError("Invalid iat claim (must be integer)") + if not isinstance(iat, (int, float)): + raise InvalidDpopProofError("Invalid iat claim (must be integer or float)") if iat < now - offset or iat > now + leeway: raise InvalidDpopProofError("DPoP Proof iat is not recent enough") - if claims["htm"] != http_method: + if claims["htm"].lower() != http_method.lower(): raise InvalidDpopProofError("DPoP Proof htm mismatch") if normalize_url_for_htu(claims["htu"]) != normalize_url_for_htu(http_url): @@ -497,7 +490,7 @@ def _build_www_authenticate( return [("WWW-Authenticate", "Bearer")] algs = " ".join(self._dpop_algorithms) - dpop_required = getattr(self.options, "dpop_required", False) + dpop_required = self.is_dpop_required() # No error details if error_code == "unauthorized" or not error_code: diff --git a/packages/auth0_api_python/src/auth0_api_python/utils.py b/packages/auth0_api_python/src/auth0_api_python/utils.py index 6120541..45b2723 100644 --- a/packages/auth0_api_python/src/auth0_api_python/utils.py +++ b/packages/auth0_api_python/src/auth0_api_python/utils.py @@ -54,20 +54,20 @@ async def fetch_jwks( return resp.json() -async def get_unverified_header(token: Union[str, bytes]) -> dict: +def get_unverified_header(token: Union[str, bytes]) -> dict: """ Parse the first segment (header) of a JWT without verifying signature. - Ensures correct Base64 padding before decode to avoid garbage bytes. + Ensures correct Base64 padding before decode to avoid garbage bytes.\ """ if isinstance(token, bytes): token = token.decode("utf-8") - try: - header_b64, _, _ = token.split(".", 2) - except ValueError as e: - raise ValueError("Not enough segments in token") from e - header_b64 = remove_bytes_prefix(header_b64) + parts = token.split(".") + if len(parts) != 3: + raise ValueError(f"Invalid token format: expected 3 segments, got {len(parts)}") + header_b64 = parts[0] + header_b64 = remove_bytes_prefix(header_b64) header_b64 = fix_base64_padding(header_b64) header_data = base64.urlsafe_b64decode(header_b64) From d5a1606871e7eea84e6fc933028216b0dac0ebca Mon Sep 17 00:00:00 2001 From: Snehil Kishore Date: Thu, 31 Jul 2025 21:10:39 +0530 Subject: [PATCH 13/23] refactor: simplified JWK handling and iat error messages --- packages/auth0_api_python/README.md | 2 +- .../src/auth0_api_python/api_client.py | 10 +- .../src/auth0_api_python/token_utils.py | 3 +- .../src/auth0_api_python/utils.py | 2 +- .../auth0_api_python/tests/test_api_client.py | 145 ++++++++++-------- 5 files changed, 87 insertions(+), 75 deletions(-) diff --git a/packages/auth0_api_python/README.md b/packages/auth0_api_python/README.md index 5dfed63..97f7cd8 100644 --- a/packages/auth0_api_python/README.md +++ b/packages/auth0_api_python/README.md @@ -13,7 +13,7 @@ This SDK provides comprehensive support for securing APIs with Auth0-issued acce ### **Authentication Schemes** - **Bearer Token Authentication** - Traditional OAuth 2.0 Bearer tokens (RS256) - **DPoP Authentication** - Enhanced security with Demonstrating Proof-of-Possession (ES256) -- **Mixed Mode Support** - Seamlessly handle both Bearer and DPoP in the same API +- **Mixed Mode Support** - Seamlessly handles both Bearer and DPoP in the same API ### **Core Features** - **Unified Entry Point**: `verify_request()` - automatically detects and validates Bearer or DPoP schemes diff --git a/packages/auth0_api_python/src/auth0_api_python/api_client.py b/packages/auth0_api_python/src/auth0_api_python/api_client.py index 7e2a3bc..8868445 100644 --- a/packages/auth0_api_python/src/auth0_api_python/api_client.py +++ b/packages/auth0_api_python/src/auth0_api_python/api_client.py @@ -367,8 +367,10 @@ async def verify_dpop_proof( if not isinstance(iat, (int, float)): raise InvalidDpopProofError("Invalid iat claim (must be integer or float)") - if iat < now - offset or iat > now + leeway: - raise InvalidDpopProofError("DPoP Proof iat is not recent enough") + if iat < now - offset: + raise InvalidDpopProofError("DPoP Proof iat is too old") + elif iat > now + leeway: + raise InvalidDpopProofError("DPoP Proof iat is from the future") if claims["htm"].lower() != http_method.lower(): raise InvalidDpopProofError("DPoP Proof htm mismatch") @@ -444,8 +446,8 @@ def _prepare_error(self, error: BaseAuthError, auth_scheme: Optional[str] = None error_description = error.get_error_description() www_auth_headers = self._build_www_authenticate( - error_code=error_code if error_code != "unauthorized" else None, - error_description=error_description if error_code != "unauthorized" else None, + error_code=error_code, + error_description=error_description, auth_scheme=auth_scheme ) diff --git a/packages/auth0_api_python/src/auth0_api_python/token_utils.py b/packages/auth0_api_python/src/auth0_api_python/token_utils.py index 755a38a..6984ade 100644 --- a/packages/auth0_api_python/src/auth0_api_python/token_utils.py +++ b/packages/auth0_api_python/src/auth0_api_python/token_utils.py @@ -201,8 +201,7 @@ async def generate_token_with_cnf( if jkt_thumbprint is None: - public_jwk = {k: v for k, v in PRIVATE_EC_JWK.items() if k != "d"} - jkt_thumbprint = calculate_jwk_thumbprint(public_jwk) + jkt_thumbprint = calculate_jwk_thumbprint(PRIVATE_EC_JWK) existing_claims = kwargs.get('claims', {}) diff --git a/packages/auth0_api_python/src/auth0_api_python/utils.py b/packages/auth0_api_python/src/auth0_api_python/utils.py index 45b2723..7357bf5 100644 --- a/packages/auth0_api_python/src/auth0_api_python/utils.py +++ b/packages/auth0_api_python/src/auth0_api_python/utils.py @@ -57,7 +57,7 @@ async def fetch_jwks( def get_unverified_header(token: Union[str, bytes]) -> dict: """ Parse the first segment (header) of a JWT without verifying signature. - Ensures correct Base64 padding before decode to avoid garbage bytes.\ + Ensures correct Base64 padding before decode to avoid garbage bytes. """ if isinstance(token, bytes): token = token.decode("utf-8") diff --git a/packages/auth0_api_python/tests/test_api_client.py b/packages/auth0_api_python/tests/test_api_client.py index 645136a..ebfd2c5 100644 --- a/packages/auth0_api_python/tests/test_api_client.py +++ b/packages/auth0_api_python/tests/test_api_client.py @@ -18,11 +18,12 @@ generate_dpop_proof, generate_token, generate_token_with_cnf, + sha256_base64url, ) from pytest_httpx import HTTPXMock -# Create public RSA JWK by excluding private key components -PUBLIC_RSA_JWK = {k: v for k, v in PRIVATE_JWK.items() if k not in ["d", "p", "q", "dp", "dq", "qi"]} +# Create public RSA JWK by selecting only public key components +PUBLIC_RSA_JWK = {k: PRIVATE_JWK[k] for k in ["kty", "n", "e", "alg", "use", "kid"] if k in PRIVATE_JWK} @pytest.mark.asyncio async def test_init_missing_args(): @@ -448,7 +449,13 @@ async def test_verify_dpop_proof_successfully(): http_method="GET", http_url="https://api.example.com/resource" ) - assert claims["jti"] # Verify it has the required jti claim + assert claims["jti"] # Verify it has the required jti claim + assert claims["htm"] == "GET" + assert claims["htu"] == "https://api.example.com/resource" + assert isinstance(claims["iat"], int) + expected_ath = sha256_base64url(access_token) + assert claims["ath"] == expected_ath + # --- Header Validation Tests --- @@ -520,7 +527,33 @@ async def test_verify_dpop_proof_fail_no_http_method_url(): http_url="https://api.example.com/resource" ) - assert "http_method" in str(err.value).lower() or "http_url" in str(err.value).lower() + assert "http_method" in str(err.value).lower() + +@pytest.mark.asyncio +async def test_verify_dpop_proof_fail_no_http_url(): + """ + Test that verify_dpop_proof fails when http_url is missing. + """ + access_token = "test_token" + dpop_proof = await generate_dpop_proof( + access_token=access_token, + http_method="GET", + http_url="https://api.example.com/resource" + ) + + api_client = ApiClient( + ApiClientOptions(domain="auth0.local", audience="my-audience") + ) + + with pytest.raises(MissingRequiredArgumentError) as err: + await api_client.verify_dpop_proof( + access_token=access_token, + proof=dpop_proof, + http_method="GET", + http_url="" # Empty url + ) + + assert "http_url" in str(err.value).lower() # --- Claim Validation Tests --- @@ -731,9 +764,9 @@ async def test_verify_dpop_proof_fail_no_iat(): assert "missing required claim" in str(err.value).lower() @pytest.mark.asyncio -async def test_verify_dpop_proof_fail_invalid_iat_timing(): +async def test_verify_dpop_proof_fail_invalid_iat_in_future(): """ - Test that a DPoP proof with invalid 'iat' timing fails verification. + Test IAT validation with a timestamp in the future. """ access_token = "test_token" # Use a future timestamp (more than leeway allows) @@ -757,7 +790,7 @@ async def test_verify_dpop_proof_fail_invalid_iat_timing(): http_url="https://api.example.com/resource" ) - assert "iat is not recent enough" in str(err.value).lower() + assert "iat is from the future" in str(err.value).lower() @pytest.mark.asyncio async def test_verify_dpop_proof_iat_exact_boundary_conditions(): @@ -792,9 +825,9 @@ async def test_verify_dpop_proof_iat_exact_boundary_conditions(): assert result is not None @pytest.mark.asyncio -async def test_verify_dpop_proof_iat_past_offset_boundary(): +async def test_verify_dpop_proof_iat_in_past(): """ - Test IAT validation with timestamps too far in the past. + Test IAT validation with timestamp in the past. """ access_token = "test_token" # Use a timestamp too far in the past @@ -818,7 +851,7 @@ async def test_verify_dpop_proof_iat_past_offset_boundary(): http_url="https://api.example.com/resource" ) - assert "iat is not recent enough" in str(err.value).lower() + assert "iat is too old" in str(err.value).lower() @pytest.mark.asyncio async def test_verify_dpop_proof_iat_clock_skew_scenarios(): @@ -852,9 +885,9 @@ async def test_verify_dpop_proof_iat_clock_skew_scenarios(): # --- JTI (JWT ID) Validation Tests --- @pytest.mark.asyncio -async def test_verify_dpop_proof_fail_no_jti(): +async def test_verify_dpop_proof_fail_empty_jti(): """ - Test that a DPoP proof missing 'jti' claim fails verification. + Test that a DPoP proof with empty 'jti' claim fails verification. """ access_token = "test_token" dpop_proof = await generate_dpop_proof( @@ -879,9 +912,9 @@ async def test_verify_dpop_proof_fail_no_jti(): assert "jti claim must not be empty" in str(err.value).lower() @pytest.mark.asyncio -async def test_verify_dpop_proof_jti_uniqueness_scenarios(): +async def test_verify_dpop_proof_custom_jti_value(): """ - Test JTI uniqueness and replay protection scenarios. + Test for a custom JTI value. """ access_token = "test_token" @@ -908,6 +941,37 @@ async def test_verify_dpop_proof_jti_uniqueness_scenarios(): assert result is not None assert result["jti"] == custom_jti +@pytest.mark.asyncio +async def test_verify_dpop_proof_with_missing_jti(): + """Test verify_dpop_proof with missing jti claim.""" + access_token = "test_token" + + dpop_proof = await generate_dpop_proof( + access_token=access_token, + http_method="GET", + http_url="https://api.example.com/resource", + jti=None, + claims={"jti": None} + ) + + parts = dpop_proof.split('.') + if len(parts) == 3: + header, payload, signature = parts + decoded_payload = json.loads(base64.urlsafe_b64decode(payload + '=' * (4 - len(payload) % 4)).decode('utf-8')) + del decoded_payload['jti'] + modified_payload = base64.urlsafe_b64encode(json.dumps(decoded_payload).encode('utf-8')).decode('utf-8').rstrip('=') + dpop_proof = f"{header}.{modified_payload}.{signature}" + + api_client = ApiClient(ApiClientOptions(domain="auth0.local", audience="my-audience")) + with pytest.raises(InvalidDpopProofError) as err: + await api_client.verify_dpop_proof( + access_token=access_token, + proof=dpop_proof, + http_method="GET", + http_url="https://api.example.com/resource" + ) + assert "signature verification failed" in str(err.value).lower() + @pytest.mark.asyncio async def test_verify_dpop_proof_fail_htm_mismatch(): """ @@ -1152,28 +1216,6 @@ async def test_verify_dpop_proof_with_invalid_signature(): ) assert "signature verification failed" in str(e.value).lower() -@pytest.mark.asyncio -async def test_verify_dpop_proof_with_invalid_jwk_format(): - """Test verify_dpop_proof with invalid JWK format.""" - access_token = "test_token" - - dpop_proof = await generate_dpop_proof( - access_token=access_token, - http_method="GET", - http_url="https://api.example.com/resource", - header_overrides={"jwk": "not-a-valid-jwk-object"} - ) - - api_client = ApiClient(ApiClientOptions(domain="auth0.local", audience="my-audience")) - with pytest.raises(InvalidDpopProofError) as err: - await api_client.verify_dpop_proof( - access_token=access_token, - proof=dpop_proof, - http_method="GET", - http_url="https://api.example.com/resource" - ) - assert "jwk" in str(err.value).lower() - @pytest.mark.asyncio async def test_verify_dpop_proof_with_missing_jwk_parameters(): """Test verify_dpop_proof with missing JWK parameters.""" @@ -1198,37 +1240,6 @@ async def test_verify_dpop_proof_with_missing_jwk_parameters(): ) assert "only ec keys are supported" in str(err.value).lower() -@pytest.mark.asyncio -async def test_verify_dpop_proof_with_missing_jti(): - """Test verify_dpop_proof with missing jti claim.""" - access_token = "test_token" - - dpop_proof = await generate_dpop_proof( - access_token=access_token, - http_method="GET", - http_url="https://api.example.com/resource", - jti=None, - claims={"jti": None} - ) - - parts = dpop_proof.split('.') - if len(parts) == 3: - header, payload, signature = parts - decoded_payload = json.loads(base64.urlsafe_b64decode(payload + '=' * (4 - len(payload) % 4)).decode('utf-8')) - del decoded_payload['jti'] - modified_payload = base64.urlsafe_b64encode(json.dumps(decoded_payload).encode('utf-8')).decode('utf-8').rstrip('=') - dpop_proof = f"{header}.{modified_payload}.{signature}" - - api_client = ApiClient(ApiClientOptions(domain="auth0.local", audience="my-audience")) - with pytest.raises(InvalidDpopProofError) as err: - await api_client.verify_dpop_proof( - access_token=access_token, - proof=dpop_proof, - http_method="GET", - http_url="https://api.example.com/resource" - ) - assert "signature verification failed" in str(err.value).lower() - # VERIFY_REQUEST TESTS # --- Success Tests --- From e6afc56f150f0dd992c3ddd1e004794e498f2a1d Mon Sep 17 00:00:00 2001 From: Snehil Kishore Date: Thu, 31 Jul 2025 21:39:32 +0530 Subject: [PATCH 14/23] refactor: reorganize test cases --- .../auth0_api_python/tests/test_api_client.py | 116 ++++++++---------- 1 file changed, 49 insertions(+), 67 deletions(-) diff --git a/packages/auth0_api_python/tests/test_api_client.py b/packages/auth0_api_python/tests/test_api_client.py index ebfd2c5..70aa112 100644 --- a/packages/auth0_api_python/tests/test_api_client.py +++ b/packages/auth0_api_python/tests/test_api_client.py @@ -734,6 +734,30 @@ async def test_verify_dpop_proof_fail_private_key_in_jwk(): assert "private key" in str(err.value).lower() +@pytest.mark.asyncio +async def test_verify_dpop_proof_with_missing_jwk_parameters(): + """Test verify_dpop_proof with missing JWK parameters.""" + access_token = "test_token" + + incomplete_jwk = {"kty": "RSA"} + + dpop_proof = await generate_dpop_proof( + access_token=access_token, + http_method="GET", + http_url="https://api.example.com/resource", + header_overrides={"jwk": incomplete_jwk} + ) + + api_client = ApiClient(ApiClientOptions(domain="auth0.local", audience="my-audience")) + with pytest.raises(InvalidDpopProofError) as err: + await api_client.verify_dpop_proof( + access_token=access_token, + proof=dpop_proof, + http_method="GET", + http_url="https://api.example.com/resource" + ) + assert "only ec keys are supported" in str(err.value).lower() + # --- IAT (Issued At Time) Validation Tests --- @pytest.mark.asyncio @@ -854,9 +878,9 @@ async def test_verify_dpop_proof_iat_in_past(): assert "iat is too old" in str(err.value).lower() @pytest.mark.asyncio -async def test_verify_dpop_proof_iat_clock_skew_scenarios(): +async def test_verify_dpop_proof_iat_within_leeway(): """ - Test IAT validation with various clock skew scenarios. + Test that IAT timestamps within acceptable leeway pass validation. """ access_token = "test_token" current_time = int(time.time()) @@ -1055,9 +1079,9 @@ async def test_verify_dpop_proof_htu_url_normalization_case_sensitivity(): @pytest.mark.asyncio -async def test_verify_dpop_proof_htu_trailing_slash_normalization(): +async def test_verify_dpop_proof_htu_trailing_slash_mismatch(): """ - Test HTU URL normalization with trailing slashes: should fail because path difference is significant. + Test that HTU URLs with trailing slash differences cause verification failure. """ access_token = "test_token" # Generate proof with trailing slash @@ -1216,30 +1240,6 @@ async def test_verify_dpop_proof_with_invalid_signature(): ) assert "signature verification failed" in str(e.value).lower() -@pytest.mark.asyncio -async def test_verify_dpop_proof_with_missing_jwk_parameters(): - """Test verify_dpop_proof with missing JWK parameters.""" - access_token = "test_token" - - incomplete_jwk = {"kty": "RSA"} - - dpop_proof = await generate_dpop_proof( - access_token=access_token, - http_method="GET", - http_url="https://api.example.com/resource", - header_overrides={"jwk": incomplete_jwk} - ) - - api_client = ApiClient(ApiClientOptions(domain="auth0.local", audience="my-audience")) - with pytest.raises(InvalidDpopProofError) as err: - await api_client.verify_dpop_proof( - access_token=access_token, - proof=dpop_proof, - http_method="GET", - http_url="https://api.example.com/resource" - ) - assert "only ec keys are supported" in str(err.value).lower() - # VERIFY_REQUEST TESTS # --- Success Tests --- @@ -1442,7 +1442,7 @@ async def test_verify_request_fail_dpop_disabled(): http_url="https://api.example.com/resource" ) - assert isinstance(err.value, MissingAuthorizationError) + assert err.value.get_status_code() == 401 @pytest.mark.asyncio async def test_verify_request_fail_missing_authorization_header(): @@ -1453,44 +1453,48 @@ async def test_verify_request_fail_missing_authorization_header(): ApiClientOptions(domain="auth0.local", audience="my-audience") ) - with pytest.raises(MissingAuthorizationError): + with pytest.raises(MissingAuthorizationError) as err: await api_client.verify_request( headers={}, http_method="GET", http_url="https://api.example.com/resource" ) + assert err.value.get_status_code() == 401 @pytest.mark.asyncio -async def test_verify_request_fail_malformed_authorization_header(): +async def test_verify_request_fail_unsupported_scheme(): """ - Test that malformed Authorization headers are rejected. + Test that unsupported authentication schemes are rejected. """ api_client = ApiClient( ApiClientOptions(domain="auth0.local", audience="my-audience") ) - with pytest.raises(MissingAuthorizationError): + with pytest.raises(MissingAuthorizationError) as err: await api_client.verify_request( - headers={"authorization": "InvalidFormat"}, # Missing scheme and token + headers={"authorization": "Basic dXNlcjpwYXNz"}, http_method="GET", http_url="https://api.example.com/resource" ) + assert err.value.get_status_code() == 401 @pytest.mark.asyncio -async def test_verify_request_fail_unsupported_scheme(): - """ - Test that unsupported authentication schemes are rejected. - """ +async def test_verify_request_fail_empty_bearer_token(): + """Test verify_request with empty token value.""" + api_client = ApiClient(ApiClientOptions(domain="auth0.local", audience="my-audience")) + with pytest.raises(MissingAuthorizationError) as err: + await api_client.verify_request({"Authorization": "Bearer "}) + assert err.value.get_status_code() == 401 + +@pytest.mark.asyncio +async def test_verify_request_with_multiple_spaces_in_authorization(): + """Test verify_request with authorization header containing multiple spaces.""" api_client = ApiClient( ApiClientOptions(domain="auth0.local", audience="my-audience") ) - - with pytest.raises(MissingAuthorizationError): - await api_client.verify_request( - headers={"authorization": "Basic dXNlcjpwYXNz"}, - http_method="GET", - http_url="https://api.example.com/resource" - ) + with pytest.raises(InvalidAuthSchemeError) as err: + await api_client.verify_request({"authorization": "Bearer token with extra spaces"}) + assert "authorization" in str(err.value).lower() @pytest.mark.asyncio async def test_verify_request_fail_missing_dpop_header(): @@ -1542,26 +1546,4 @@ async def test_verify_request_fail_multiple_dpop_proofs(): assert "multiple" in str(err.value).lower() -@pytest.mark.asyncio -async def test_verify_request_with_empty_token(): - """Test verify_request with empty token value.""" - api_client = ApiClient(ApiClientOptions(domain="auth0.local", audience="my-audience")) - with pytest.raises(MissingAuthorizationError): - await api_client.verify_request({"Authorization": "Bearer "}) - -@pytest.mark.asyncio -async def test_verify_request_with_multiple_spaces_in_authorization(): - """Test verify_request with authorization header containing multiple spaces.""" - api_client = ApiClient( - ApiClientOptions(domain="auth0.local", audience="my-audience") - ) - with pytest.raises(InvalidAuthSchemeError) as err: - await api_client.verify_request({"authorization": "Bearer token with extra spaces"}) - assert "authorization" in str(err.value).lower() -@pytest.mark.asyncio -async def test_verify_request_with_mixed_case_authorization_header(): - """Test verify_request with mixed case authorization header.""" - api_client = ApiClient(ApiClientOptions(domain="auth0.local", audience="my-audience")) - with pytest.raises(MissingAuthorizationError): - await api_client.verify_request({"AuThOrIzAtIoN": "Bearer token"}) From 41fb87a2017c6b3c7ae44fbc15539a55b86fffa5 Mon Sep 17 00:00:00 2001 From: Snehil Kishore Date: Thu, 31 Jul 2025 21:44:50 +0530 Subject: [PATCH 15/23] test: update error message assertions for DPoP validation failures --- packages/auth0_api_python/tests/test_api_client.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/auth0_api_python/tests/test_api_client.py b/packages/auth0_api_python/tests/test_api_client.py index 70aa112..b0443b0 100644 --- a/packages/auth0_api_python/tests/test_api_client.py +++ b/packages/auth0_api_python/tests/test_api_client.py @@ -1367,7 +1367,7 @@ async def test_verify_request_fail_dpop_required_mode(): http_url="https://api.example.com/resource" ) - assert "dpop" in str(err.value).lower() or "bearer" in str(err.value).lower() + assert "expected dpop, but got bearer" in str(err.value).lower() @pytest.mark.asyncio async def test_verify_request_fail_dpop_enabled_bearer_with_cnf_conflict(httpx_mock: HTTPXMock): @@ -1413,7 +1413,7 @@ async def test_verify_request_fail_dpop_enabled_bearer_with_cnf_conflict(httpx_m http_url="https://api.example.com/resource" ) - assert "cnf" in str(err.value).lower() or "dpop" in str(err.value).lower() + assert "request's authorization http header scheme is not dpop" in str(err.value).lower() @pytest.mark.asyncio async def test_verify_request_fail_dpop_disabled(): From b6c901ea167be47f7bcf52b81b8dfb96cf4de924 Mon Sep 17 00:00:00 2001 From: Snehil Kishore Date: Thu, 31 Jul 2025 22:11:45 +0530 Subject: [PATCH 16/23] feat: add include_jti flag to control jti claim inclusion in DPoP proof generation --- .../src/auth0_api_python/token_utils.py | 13 ++++++++----- packages/auth0_api_python/tests/test_api_client.py | 14 +++----------- 2 files changed, 11 insertions(+), 16 deletions(-) diff --git a/packages/auth0_api_python/src/auth0_api_python/token_utils.py b/packages/auth0_api_python/src/auth0_api_python/token_utils.py index 6984ade..c234681 100644 --- a/packages/auth0_api_python/src/auth0_api_python/token_utils.py +++ b/packages/auth0_api_python/src/auth0_api_python/token_utils.py @@ -106,7 +106,8 @@ async def generate_dpop_proof( iat: bool = True, claims: Optional[dict[str, Any]] = None, header_overrides: Optional[dict[str, Any]] = None, - iat_time: Optional[int] = None + iat_time: Optional[int] = None, + include_jti: bool = True ) -> str: """ Generates a real ES256-signed DPoP proof JWT using the EC private key above. @@ -120,6 +121,7 @@ async def generate_dpop_proof( claims: Additional custom claims to merge into the proof. header_overrides: Override header parameters (e.g., for testing invalid headers). iat_time: Fixed time for iat claim (for testing). If None, uses current time. + include_jti: Whether to include the 'jti' claim. If False, jti is completely omitted. Returns: An ES256-signed DPoP proof JWT string. @@ -140,10 +142,11 @@ async def generate_dpop_proof( if iat: proof_claims["iat"] = iat_time if iat_time is not None else int(time.time()) - if jti is not None: - proof_claims["jti"] = jti - else: - proof_claims["jti"] = str(uuid.uuid4()) + if include_jti: + if jti is not None: + proof_claims["jti"] = jti + else: + proof_claims["jti"] = str(uuid.uuid4()) proof_claims["htm"] = http_method proof_claims["htu"] = normalize_url_for_htu(http_url) diff --git a/packages/auth0_api_python/tests/test_api_client.py b/packages/auth0_api_python/tests/test_api_client.py index b0443b0..4de1de6 100644 --- a/packages/auth0_api_python/tests/test_api_client.py +++ b/packages/auth0_api_python/tests/test_api_client.py @@ -970,22 +970,14 @@ async def test_verify_dpop_proof_with_missing_jti(): """Test verify_dpop_proof with missing jti claim.""" access_token = "test_token" + # Generate DPoP proof WITHOUT jti claim from the start dpop_proof = await generate_dpop_proof( access_token=access_token, http_method="GET", http_url="https://api.example.com/resource", - jti=None, - claims={"jti": None} + include_jti=False # Completely omit jti claim ) - parts = dpop_proof.split('.') - if len(parts) == 3: - header, payload, signature = parts - decoded_payload = json.loads(base64.urlsafe_b64decode(payload + '=' * (4 - len(payload) % 4)).decode('utf-8')) - del decoded_payload['jti'] - modified_payload = base64.urlsafe_b64encode(json.dumps(decoded_payload).encode('utf-8')).decode('utf-8').rstrip('=') - dpop_proof = f"{header}.{modified_payload}.{signature}" - api_client = ApiClient(ApiClientOptions(domain="auth0.local", audience="my-audience")) with pytest.raises(InvalidDpopProofError) as err: await api_client.verify_dpop_proof( @@ -994,7 +986,7 @@ async def test_verify_dpop_proof_with_missing_jti(): http_method="GET", http_url="https://api.example.com/resource" ) - assert "signature verification failed" in str(err.value).lower() + assert "missing required claim: jti" in str(err.value).lower() @pytest.mark.asyncio async def test_verify_dpop_proof_fail_htm_mismatch(): From 503a6a71235b2d5363e840b461531aa4caa6b0dd Mon Sep 17 00:00:00 2001 From: Snehil Kishore Date: Sat, 2 Aug 2025 00:14:32 +0530 Subject: [PATCH 17/23] fix: improve error message formatting for DPoP scheme validation --- .github/workflows/release.yml | 5 + packages/auth0_api_python/poetry.lock | 648 +++++++++--------- .../src/auth0_api_python/api_client.py | 13 +- .../auth0_api_python/tests/test_api_client.py | 2 +- 4 files changed, 352 insertions(+), 316 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 47f97ac..e230d64 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -46,6 +46,11 @@ jobs: working-directory: packages/${{ github.event.inputs.sdk }} run: poetry install --no-root + - name: Run tests with pytest + working-directory: packages/${{ github.event.inputs.sdk }} + run: | + poetry run pytest -v --cov=src --cov-report=term-missing --cov-report=xml + - name: Build package working-directory: packages/${{ github.event.inputs.sdk }} run: poetry build diff --git a/packages/auth0_api_python/poetry.lock b/packages/auth0_api_python/poetry.lock index 149605e..0a11563 100644 --- a/packages/auth0_api_python/poetry.lock +++ b/packages/auth0_api_python/poetry.lock @@ -2,63 +2,63 @@ [[package]] name = "ada-url" -version = "1.25.0" +version = "1.26.0" description = "URL parser and manipulator based on the WHAT WG URL standard" optional = false python-versions = ">=3.9" groups = ["main"] files = [ - {file = "ada_url-1.25.0-cp310-cp310-macosx_10_15_universal2.whl", hash = "sha256:745fa4448a796386f9330ecffad36c28ec319382ecae0337b97f2f91898dc6e6"}, - {file = "ada_url-1.25.0-cp310-cp310-macosx_10_15_x86_64.whl", hash = "sha256:7b5edc26b9dc4890696e002c5212de0370790e512609e63449cc536fbd88a38b"}, - {file = "ada_url-1.25.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:15e65f28fe7f779204a419598947037a574221998033620e7e22c0e5ccfb67fb"}, - {file = "ada_url-1.25.0-cp310-cp310-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:412b4708f65586fb775c5554d7bd4925d9dd5bc78a602cfa862db7a841c76b94"}, - {file = "ada_url-1.25.0-cp310-cp310-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:875b71e3ad468616260e8e83e1ef3d73edcc644a1d0ed0ec6e28b437a7c16e0f"}, - {file = "ada_url-1.25.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:0837369cad9e8b6eadafd7d2d074f04a41f485a33ef570c8064ff3582bede87a"}, - {file = "ada_url-1.25.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:9912262661c50729010cb8f0de78c069ab69a164e382b5cea0abe887038ee42f"}, - {file = "ada_url-1.25.0-cp310-cp310-win_amd64.whl", hash = "sha256:cc1abb2fb0e2de443d6d6f9746b8687fb535318397da9acd74496be6999bd7ab"}, - {file = "ada_url-1.25.0-cp311-cp311-macosx_10_15_universal2.whl", hash = "sha256:567b86a7c081632b445651fe8371f891699e658d1dac29162fef4984f89b21f0"}, - {file = "ada_url-1.25.0-cp311-cp311-macosx_10_15_x86_64.whl", hash = "sha256:6dc2d79ecfa24bc5b23e4a63b0d8cc1df2350729c51144da304d22210b077907"}, - {file = "ada_url-1.25.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:9ab2b3a0aee2a9737fa9d071a82ef9bb21cd5c2638a5621680632b8b2c22883b"}, - {file = "ada_url-1.25.0-cp311-cp311-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:646c240cccb65bcadf61b934b82e0e6c9bf1842b4b0c8492fc6921612761b7f4"}, - {file = "ada_url-1.25.0-cp311-cp311-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:727e060359188bb2f4f1c2e8b27e81ef5c778634df6910334d88e050430adbe9"}, - {file = "ada_url-1.25.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:98b7007d74294b0c10ded5500769c2adf8c1ffa584692510f7e990aeee2938f2"}, - {file = "ada_url-1.25.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:55bd83d820f7a987df7989695b0d964c16ded547d7e190c9dc9cf50c26160d00"}, - {file = "ada_url-1.25.0-cp311-cp311-win_amd64.whl", hash = "sha256:8133ccc849b14465b332c5f2ff3bbe692c9c0b4112f9e07f1efbdc690df822bf"}, - {file = "ada_url-1.25.0-cp312-cp312-macosx_10_15_universal2.whl", hash = "sha256:ce48f22981eefd50f526131034bb5cbab56034e8b367475c9b8098d9ba0489ff"}, - {file = "ada_url-1.25.0-cp312-cp312-macosx_10_15_x86_64.whl", hash = "sha256:97f502975a714aafe73b8d2c3b3ea3cbdeee3081ce619c37a5a1584ec1488234"}, - {file = "ada_url-1.25.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:244eefc2f7814dd25e40682812a7f36e7d1b16b7bdfa142abd397a954d20088f"}, - {file = "ada_url-1.25.0-cp312-cp312-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c1c4efeba2ec8fc5d9ab0195cd40d045f751967f192bab3d3685c8b9e95c5294"}, - {file = "ada_url-1.25.0-cp312-cp312-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:34bfa8a95892d10f12ac2203d66ff99f1bc7600fa5e4a0f1304902c037cb7fcc"}, - {file = "ada_url-1.25.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:9a232be74ed62c92463bf0f5b48914ab25b1e0ab6f88f7c65501063bde8bbbf5"}, - {file = "ada_url-1.25.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:29ae076213bc2f63b6a06e70c676e61e76708535e84b2eff97540907933d85a2"}, - {file = "ada_url-1.25.0-cp312-cp312-win_amd64.whl", hash = "sha256:867c023d220447811f5bd211b9d3957c8870ae963ee6f50b1781fbade2afaa84"}, - {file = "ada_url-1.25.0-cp313-cp313-macosx_10_15_universal2.whl", hash = "sha256:ba4c0856fa9edfbe347f5de390f81d1b230683718bbd9f88977acaabb5f9d53c"}, - {file = "ada_url-1.25.0-cp313-cp313-macosx_10_15_x86_64.whl", hash = "sha256:7e763188236de66e1b8762b69366f0cc92a0927b570d564a4cc27700be8783e3"}, - {file = "ada_url-1.25.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:c61e68df4f0a299dd851738fc1072b78a8551166dee049c989e3d088a53dc3f9"}, - {file = "ada_url-1.25.0-cp313-cp313-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0c6d07e378cc4ef6d3ddd46d7310932577cd93dd675334927bfb426688864c54"}, - {file = "ada_url-1.25.0-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e631331fc0bb4a032bb4bf437068945b5880597ee1a466b8c7a82d5af9c8d43d"}, - {file = "ada_url-1.25.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:4bba786e270af52a72869aa7407e2003a5c025a05ad4d736b7e4e35b0cda550b"}, - {file = "ada_url-1.25.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:a16eeb4993750f960e1f61693cf0c53d3949cc3a93eb0ee330e600fd1925c7b8"}, - {file = "ada_url-1.25.0-cp313-cp313-win_amd64.whl", hash = "sha256:558c6f1f040f08515bd1bbc75c31e3f94ee5922f39fa517d577b6e08a0b31885"}, - {file = "ada_url-1.25.0-cp39-cp39-macosx_10_15_universal2.whl", hash = "sha256:f36a894e6bb66108b3c2da6108c17dfa52f654c2d8c7129acde1f2c3e0c15684"}, - {file = "ada_url-1.25.0-cp39-cp39-macosx_10_15_x86_64.whl", hash = "sha256:50e84950e583aad377bf822b11e219b0f01a5f9e32e961171acea29563f292ce"}, - {file = "ada_url-1.25.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:626234902c7c886f322eef7d3b5adfd04f8e5c309c643e3ab6e27f7216c3fb13"}, - {file = "ada_url-1.25.0-cp39-cp39-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:dd85de9f31c882896e171f3f15124556cbe7d69de9047e174e2b5b2429365bc2"}, - {file = "ada_url-1.25.0-cp39-cp39-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:acd8547d288fa7e1a3d660833f9274d85aa2a5cd592c921efccfa821a21d591a"}, - {file = "ada_url-1.25.0-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:f28dc864f71ee4e97d5fa278cba2e8ee6406715d87ed1463bf175f9935f0a611"}, - {file = "ada_url-1.25.0-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:49a81fb368e65c5594d9895c02353deca2bb677206b31fcd4256cba1396329a9"}, - {file = "ada_url-1.25.0-cp39-cp39-win_amd64.whl", hash = "sha256:fcb63bea78099df0efc0339bcbeccb5b5b06031e8fe3689dfaa69f0463e8a61a"}, - {file = "ada_url-1.25.0-pp310-pypy310_pp73-macosx_10_15_x86_64.whl", hash = "sha256:a6bddee8bcf2d505ebce2d65bb192b845f907f97e8a22d7d36dea8ac79c5462d"}, - {file = "ada_url-1.25.0-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:92dda4112e681160b10d53dc6737ffbffba2fc4f8fded15d3d8f3e99e91e19c1"}, - {file = "ada_url-1.25.0-pp310-pypy310_pp73-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a3ad3a6e75ca109dda6d352b6a289e61d05a0fcf710956e5a338f4d8afe363bf"}, - {file = "ada_url-1.25.0-pp310-pypy310_pp73-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:847a86d2ba958cc0beadf85576aa3b00e97c952d67f54a9a712b06d6e86389d6"}, - {file = "ada_url-1.25.0-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:7e969821fec735fadfaa3949376baff5c199019306cb179eba146636610c43a6"}, - {file = "ada_url-1.25.0-pp39-pypy39_pp73-macosx_10_15_x86_64.whl", hash = "sha256:bdb801046f8384957f92706a74d0a2df4799b60d17b453441d91e730fc82c5fc"}, - {file = "ada_url-1.25.0-pp39-pypy39_pp73-macosx_11_0_arm64.whl", hash = "sha256:3f7d357a634c5258eb802fa1ab9c21fb74ed12486fb93ed9f7c89bf7addf4046"}, - {file = "ada_url-1.25.0-pp39-pypy39_pp73-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:85fb9d799ef9fc75d541721c7808b4177348f786512e707c094ea81255b304e3"}, - {file = "ada_url-1.25.0-pp39-pypy39_pp73-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a2c148e832588f252cae994470dc824c1840380ea69575769b1660c2d2663b1e"}, - {file = "ada_url-1.25.0-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:32fd7078a79813d27381c94726a440032ba225404a4ceace4c2431c388b23a00"}, - {file = "ada_url-1.25.0.tar.gz", hash = "sha256:d571c82a7d5b0965776b289de76319ed432bf85bc5e3d1dc624fb44cf50561be"}, + {file = "ada_url-1.26.0-cp310-cp310-macosx_10_15_universal2.whl", hash = "sha256:c2d1578f592be814d40f0a56031809b40500f61cb240966d0ec25ba152b55eb0"}, + {file = "ada_url-1.26.0-cp310-cp310-macosx_10_15_x86_64.whl", hash = "sha256:321d581274a60f227609be9b6c0863eced4a31b5bf8219d72bf305710d58116d"}, + {file = "ada_url-1.26.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:071ca476ed5e35651cd39986faea45f100b338147d69218b8170d491d6345baf"}, + {file = "ada_url-1.26.0-cp310-cp310-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9bc2cb70aa714f4093d8406bef1c1ae8c998818dda4e512645e6fc802959fdf1"}, + {file = "ada_url-1.26.0-cp310-cp310-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f50ec59bd673941b4e9563e152d7917eda5859b834f2e63093dafecf9896d396"}, + {file = "ada_url-1.26.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:9d27819cac073dcf0909f3d884198d107d7149ccd21f8c084aed5a6eb2d4e579"}, + {file = "ada_url-1.26.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:ea2ffc0f8976d05217324844a0f18bb90c8ec6ac08c31646a4a4a6396e8af906"}, + {file = "ada_url-1.26.0-cp310-cp310-win_amd64.whl", hash = "sha256:3eb5ce4b81d1f8344d032c69af1804bc1475ba4db3d5b586e6f1dae0884fcbcf"}, + {file = "ada_url-1.26.0-cp311-cp311-macosx_10_15_universal2.whl", hash = "sha256:6eec591ed6c13b323501e2ce1f29f0dc731affb11036140119382baa08f17f3b"}, + {file = "ada_url-1.26.0-cp311-cp311-macosx_10_15_x86_64.whl", hash = "sha256:3f8298b60ddd76f2b225b4e5b16b5def61c157c1cdd856c2093b3fcaa3e98441"}, + {file = "ada_url-1.26.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:ba6d518b4bafec467c8d879a1620f0aef400307cb5ae0f96772139f66c611d56"}, + {file = "ada_url-1.26.0-cp311-cp311-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:dfbd6fb73f182ec1488ce24716d64af5fbcc8af90a511a571ca408d8f91d36ad"}, + {file = "ada_url-1.26.0-cp311-cp311-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:de7b083b700cb71490e9a716ea42c9fee3b4f973aeccde08c6e6066f1184f59a"}, + {file = "ada_url-1.26.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:272fa1ac5ec60bd1a5399c824e63bbd3084ab1410cb89c5497cc1b3e93513cf2"}, + {file = "ada_url-1.26.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:2c244dabc7d88861efee7e822525b89fdcc8fec7d17f89ca0368a90eb401c76d"}, + {file = "ada_url-1.26.0-cp311-cp311-win_amd64.whl", hash = "sha256:db36fa791b80e2f1034c91a41ab489d3b78aead79f52173b67619bd830d3ff83"}, + {file = "ada_url-1.26.0-cp312-cp312-macosx_10_15_universal2.whl", hash = "sha256:5ad5fca18df30b93aa4196bc236aef37dfb4e8b1ade93deea14c03b9c2d87486"}, + {file = "ada_url-1.26.0-cp312-cp312-macosx_10_15_x86_64.whl", hash = "sha256:3a52d5e157738519ab504913972e3abf4e800a45574e9b431c4ee88589f213d5"}, + {file = "ada_url-1.26.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:b56a0a685c7440aa7f49ff4827bca55c03ba4c54e7b9744a867d195eca2564b7"}, + {file = "ada_url-1.26.0-cp312-cp312-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f060bfdd2774c8313f0353325aeadf9afdd940f4c0833628d3fa4b7b09fe7949"}, + {file = "ada_url-1.26.0-cp312-cp312-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e23086b6f65d21a988457cad4cc63235796b1f213a66d7173d05206690fabb69"}, + {file = "ada_url-1.26.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:f3d5b942b19a81236e1aae94bc7315fbeaceeefa2775c2f40ab9196009151da0"}, + {file = "ada_url-1.26.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:4606887898806cc4cbf19a285a1dab131e2409dc293a534c8505ef15eeac7fb7"}, + {file = "ada_url-1.26.0-cp312-cp312-win_amd64.whl", hash = "sha256:09b9a6e47d6084ac64957a947bfadab4fc1117b157cd0463091c46434bb11d01"}, + {file = "ada_url-1.26.0-cp313-cp313-macosx_10_15_universal2.whl", hash = "sha256:023c8f520ba3a2a7c389f1205d4b2a9384bd06c8cf8b48ae58c43cdb4cfa1881"}, + {file = "ada_url-1.26.0-cp313-cp313-macosx_10_15_x86_64.whl", hash = "sha256:6e8db691157ada513c5e877fd66f0cea54ef473fcf7e6bad429608f2c32d5d63"}, + {file = "ada_url-1.26.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:c158ae6850b1ac66c1dbd54a7b5dac5a2a953ca33db0cd6bcef1c97b1b5536de"}, + {file = "ada_url-1.26.0-cp313-cp313-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:dc723bf495730c22ec2890b8e5d4bbe591b73e97af6e8a862e0ca44ac4197660"}, + {file = "ada_url-1.26.0-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:dd375e12247a1d6ca190a67bc88463b60c013361d3f99e2347f8a7af2f548a1c"}, + {file = "ada_url-1.26.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:da783beac508b487d1c09a2afa35e3e14e39f164dd2c4a2d91db16ac63cfe65d"}, + {file = "ada_url-1.26.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:01e9e18dad01adc4703ffda5600c5ae0e5da547124e4ae0a74b0d30cdcf952b1"}, + {file = "ada_url-1.26.0-cp313-cp313-win_amd64.whl", hash = "sha256:bbc9c955a37c15984495d487a9e1b5ff8aa681101cd3f087faab30fab03d53c8"}, + {file = "ada_url-1.26.0-cp39-cp39-macosx_10_15_universal2.whl", hash = "sha256:e2a9fa9293f0137b04c1804fa357e906401977111cc8f7da2aa0e4971d152455"}, + {file = "ada_url-1.26.0-cp39-cp39-macosx_10_15_x86_64.whl", hash = "sha256:8678a5303a6d21d4c7639e8cb7d236f21a43615705ec841c2e0201a9c295de09"}, + {file = "ada_url-1.26.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:bf74c390d3ba9d264521d60e6c4a211aae9dbc5fd324c80bec8bfd5eea228347"}, + {file = "ada_url-1.26.0-cp39-cp39-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:51a7db1d9356dfaf3d69e8fe052ea2f301044b2ec111f050a16ea49ea53645f1"}, + {file = "ada_url-1.26.0-cp39-cp39-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2558db19ac40b1cd4d936b57724442a3340e4cd7b9ef55fa9b793fef525a12d7"}, + {file = "ada_url-1.26.0-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:28542b0b958b75f5ce6cf3121f43eeb3884d29b6e873d5aad70c9a4807938178"}, + {file = "ada_url-1.26.0-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:2d60751f85392c2e1b610fb1799b6ec496e64b6c7072bac307c7109813da5745"}, + {file = "ada_url-1.26.0-cp39-cp39-win_amd64.whl", hash = "sha256:3851518c53c8b5b2c2fb75a3571987f5669d2f37f7fe81e28e6080d42079f6e2"}, + {file = "ada_url-1.26.0-pp310-pypy310_pp73-macosx_10_15_x86_64.whl", hash = "sha256:09ea872cc1d064123586ca3c0f934daf6d2bf0ed92dfdbebf166268ec1952595"}, + {file = "ada_url-1.26.0-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:0b2df12d04f5a57d17175182fb631cc1c21b21d6a1174fc1dee26c9978cec39b"}, + {file = "ada_url-1.26.0-pp310-pypy310_pp73-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:cc6d6ce54384cae19c599b4464c391ad5208f244c885cf150957aeec81102bb7"}, + {file = "ada_url-1.26.0-pp310-pypy310_pp73-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9c6f15abd4204760419683b7457ffbc4a71c86383b10273454db4773ad3e763c"}, + {file = "ada_url-1.26.0-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:9c5018f739e2ddb092cd0f2a2a8ee0125bbf101ac93b1cbc762988b4515b1672"}, + {file = "ada_url-1.26.0-pp39-pypy39_pp73-macosx_10_15_x86_64.whl", hash = "sha256:476b5ff71d89ce07ddc8d059c404f754582ad66946f6eb4ebb8a3162c917bc79"}, + {file = "ada_url-1.26.0-pp39-pypy39_pp73-macosx_11_0_arm64.whl", hash = "sha256:8d9e4fdc053711d42bb1ca3a1d1b201fa4628b6fdc8c65bdf158e9ec3ad1be0a"}, + {file = "ada_url-1.26.0-pp39-pypy39_pp73-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:80bee51c57e53b878c1855b4c97c4037d5d1d35f83ade0f3664e82f2e9259ca3"}, + {file = "ada_url-1.26.0-pp39-pypy39_pp73-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e666ea81c54d8c705fa6262ef502fa483d6ca48727c6340f488f98d1d4716147"}, + {file = "ada_url-1.26.0-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:d4e1a6d4d60d3603233b4dd6c3e461d25768d7127c346fc6dcd83920a619500e"}, + {file = "ada_url-1.26.0.tar.gz", hash = "sha256:87988926d78a68bc08de0595362163fa3d3126bf9e0223aaf9d98272de2625f4"}, ] [package.dependencies] @@ -89,14 +89,14 @@ trio = ["trio (>=0.26.1)"] [[package]] name = "authlib" -version = "1.5.2" +version = "1.6.1" description = "The ultimate Python library in building OAuth and OpenID Connect servers and clients." optional = false python-versions = ">=3.9" groups = ["main"] files = [ - {file = "authlib-1.5.2-py2.py3-none-any.whl", hash = "sha256:8804dd4402ac5e4a0435ac49e0b6e19e395357cfa632a3f624dcb4f6df13b4b1"}, - {file = "authlib-1.5.2.tar.gz", hash = "sha256:fe85ec7e50c5f86f1e2603518bb3b4f632985eb4a355e52256530790e326c512"}, + {file = "authlib-1.6.1-py2.py3-none-any.whl", hash = "sha256:e9d2031c34c6309373ab845afc24168fe9e93dc52d252631f52642f21f5ed06e"}, + {file = "authlib-1.6.1.tar.gz", hash = "sha256:4dffdbb1460ba6ec8c17981a4c67af7d8af131231b5a36a88a1e8c80c111cdfd"}, ] [package.dependencies] @@ -121,14 +121,14 @@ testing = ["jaraco.test", "pytest (!=8.0.*)", "pytest (>=6,!=8.1.*)", "pytest-ch [[package]] name = "certifi" -version = "2025.1.31" +version = "2025.7.14" description = "Python package for providing Mozilla's CA Bundle." optional = false -python-versions = ">=3.6" +python-versions = ">=3.7" groups = ["main", "dev"] files = [ - {file = "certifi-2025.1.31-py3-none-any.whl", hash = "sha256:ca78db4565a652026a4db2bcdf68f2fb589ea80d0be70e03929ed730746b84fe"}, - {file = "certifi-2025.1.31.tar.gz", hash = "sha256:3d5da6925056f6f18f119200434a4780a94263f10d1c21d032a6f6b2baa20651"}, + {file = "certifi-2025.7.14-py3-none-any.whl", hash = "sha256:6b31f564a415d79ee77df69d757bb49a5bb53bd9f756cbbe24394ffd6fc1f4b2"}, + {file = "certifi-2025.7.14.tar.gz", hash = "sha256:8ea99dbdfaaf2ba2f9bac77b9249ef62ec5218e7c2b2e903378ed5fccf765995"}, ] [[package]] @@ -214,104 +214,104 @@ pycparser = "*" [[package]] name = "charset-normalizer" -version = "3.4.1" +version = "3.4.2" description = "The Real First Universal Charset Detector. Open, modern and actively maintained alternative to Chardet." optional = false python-versions = ">=3.7" groups = ["main", "dev"] files = [ - {file = "charset_normalizer-3.4.1-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:91b36a978b5ae0ee86c394f5a54d6ef44db1de0815eb43de826d41d21e4af3de"}, - {file = "charset_normalizer-3.4.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7461baadb4dc00fd9e0acbe254e3d7d2112e7f92ced2adc96e54ef6501c5f176"}, - {file = "charset_normalizer-3.4.1-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e218488cd232553829be0664c2292d3af2eeeb94b32bea483cf79ac6a694e037"}, - {file = "charset_normalizer-3.4.1-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:80ed5e856eb7f30115aaf94e4a08114ccc8813e6ed1b5efa74f9f82e8509858f"}, - {file = "charset_normalizer-3.4.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b010a7a4fd316c3c484d482922d13044979e78d1861f0e0650423144c616a46a"}, - {file = "charset_normalizer-3.4.1-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:4532bff1b8421fd0a320463030c7520f56a79c9024a4e88f01c537316019005a"}, - {file = "charset_normalizer-3.4.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:d973f03c0cb71c5ed99037b870f2be986c3c05e63622c017ea9816881d2dd247"}, - {file = "charset_normalizer-3.4.1-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:3a3bd0dcd373514dcec91c411ddb9632c0d7d92aed7093b8c3bbb6d69ca74408"}, - {file = "charset_normalizer-3.4.1-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:d9c3cdf5390dcd29aa8056d13e8e99526cda0305acc038b96b30352aff5ff2bb"}, - {file = "charset_normalizer-3.4.1-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:2bdfe3ac2e1bbe5b59a1a63721eb3b95fc9b6817ae4a46debbb4e11f6232428d"}, - {file = "charset_normalizer-3.4.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:eab677309cdb30d047996b36d34caeda1dc91149e4fdca0b1a039b3f79d9a807"}, - {file = "charset_normalizer-3.4.1-cp310-cp310-win32.whl", hash = "sha256:c0429126cf75e16c4f0ad00ee0eae4242dc652290f940152ca8c75c3a4b6ee8f"}, - {file = "charset_normalizer-3.4.1-cp310-cp310-win_amd64.whl", hash = "sha256:9f0b8b1c6d84c8034a44893aba5e767bf9c7a211e313a9605d9c617d7083829f"}, - {file = "charset_normalizer-3.4.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:8bfa33f4f2672964266e940dd22a195989ba31669bd84629f05fab3ef4e2d125"}, - {file = "charset_normalizer-3.4.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:28bf57629c75e810b6ae989f03c0828d64d6b26a5e205535585f96093e405ed1"}, - {file = "charset_normalizer-3.4.1-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f08ff5e948271dc7e18a35641d2f11a4cd8dfd5634f55228b691e62b37125eb3"}, - {file = "charset_normalizer-3.4.1-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:234ac59ea147c59ee4da87a0c0f098e9c8d169f4dc2a159ef720f1a61bbe27cd"}, - {file = "charset_normalizer-3.4.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fd4ec41f914fa74ad1b8304bbc634b3de73d2a0889bd32076342a573e0779e00"}, - {file = "charset_normalizer-3.4.1-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:eea6ee1db730b3483adf394ea72f808b6e18cf3cb6454b4d86e04fa8c4327a12"}, - {file = "charset_normalizer-3.4.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:c96836c97b1238e9c9e3fe90844c947d5afbf4f4c92762679acfe19927d81d77"}, - {file = "charset_normalizer-3.4.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:4d86f7aff21ee58f26dcf5ae81a9addbd914115cdebcbb2217e4f0ed8982e146"}, - {file = "charset_normalizer-3.4.1-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:09b5e6733cbd160dcc09589227187e242a30a49ca5cefa5a7edd3f9d19ed53fd"}, - {file = "charset_normalizer-3.4.1-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:5777ee0881f9499ed0f71cc82cf873d9a0ca8af166dfa0af8ec4e675b7df48e6"}, - {file = "charset_normalizer-3.4.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:237bdbe6159cff53b4f24f397d43c6336c6b0b42affbe857970cefbb620911c8"}, - {file = "charset_normalizer-3.4.1-cp311-cp311-win32.whl", hash = "sha256:8417cb1f36cc0bc7eaba8ccb0e04d55f0ee52df06df3ad55259b9a323555fc8b"}, - {file = "charset_normalizer-3.4.1-cp311-cp311-win_amd64.whl", hash = "sha256:d7f50a1f8c450f3925cb367d011448c39239bb3eb4117c36a6d354794de4ce76"}, - {file = "charset_normalizer-3.4.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:73d94b58ec7fecbc7366247d3b0b10a21681004153238750bb67bd9012414545"}, - {file = "charset_normalizer-3.4.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:dad3e487649f498dd991eeb901125411559b22e8d7ab25d3aeb1af367df5efd7"}, - {file = "charset_normalizer-3.4.1-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:c30197aa96e8eed02200a83fba2657b4c3acd0f0aa4bdc9f6c1af8e8962e0757"}, - {file = "charset_normalizer-3.4.1-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2369eea1ee4a7610a860d88f268eb39b95cb588acd7235e02fd5a5601773d4fa"}, - {file = "charset_normalizer-3.4.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bc2722592d8998c870fa4e290c2eec2c1569b87fe58618e67d38b4665dfa680d"}, - {file = "charset_normalizer-3.4.1-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ffc9202a29ab3920fa812879e95a9e78b2465fd10be7fcbd042899695d75e616"}, - {file = "charset_normalizer-3.4.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:804a4d582ba6e5b747c625bf1255e6b1507465494a40a2130978bda7b932c90b"}, - {file = "charset_normalizer-3.4.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:0f55e69f030f7163dffe9fd0752b32f070566451afe180f99dbeeb81f511ad8d"}, - {file = "charset_normalizer-3.4.1-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:c4c3e6da02df6fa1410a7680bd3f63d4f710232d3139089536310d027950696a"}, - {file = "charset_normalizer-3.4.1-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:5df196eb874dae23dcfb968c83d4f8fdccb333330fe1fc278ac5ceeb101003a9"}, - {file = "charset_normalizer-3.4.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:e358e64305fe12299a08e08978f51fc21fac060dcfcddd95453eabe5b93ed0e1"}, - {file = "charset_normalizer-3.4.1-cp312-cp312-win32.whl", hash = "sha256:9b23ca7ef998bc739bf6ffc077c2116917eabcc901f88da1b9856b210ef63f35"}, - {file = "charset_normalizer-3.4.1-cp312-cp312-win_amd64.whl", hash = "sha256:6ff8a4a60c227ad87030d76e99cd1698345d4491638dfa6673027c48b3cd395f"}, - {file = "charset_normalizer-3.4.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:aabfa34badd18f1da5ec1bc2715cadc8dca465868a4e73a0173466b688f29dda"}, - {file = "charset_normalizer-3.4.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:22e14b5d70560b8dd51ec22863f370d1e595ac3d024cb8ad7d308b4cd95f8313"}, - {file = "charset_normalizer-3.4.1-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8436c508b408b82d87dc5f62496973a1805cd46727c34440b0d29d8a2f50a6c9"}, - {file = "charset_normalizer-3.4.1-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2d074908e1aecee37a7635990b2c6d504cd4766c7bc9fc86d63f9c09af3fa11b"}, - {file = "charset_normalizer-3.4.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:955f8851919303c92343d2f66165294848d57e9bba6cf6e3625485a70a038d11"}, - {file = "charset_normalizer-3.4.1-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:44ecbf16649486d4aebafeaa7ec4c9fed8b88101f4dd612dcaf65d5e815f837f"}, - {file = "charset_normalizer-3.4.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:0924e81d3d5e70f8126529951dac65c1010cdf117bb75eb02dd12339b57749dd"}, - {file = "charset_normalizer-3.4.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:2967f74ad52c3b98de4c3b32e1a44e32975e008a9cd2a8cc8966d6a5218c5cb2"}, - {file = "charset_normalizer-3.4.1-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:c75cb2a3e389853835e84a2d8fb2b81a10645b503eca9bcb98df6b5a43eb8886"}, - {file = "charset_normalizer-3.4.1-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:09b26ae6b1abf0d27570633b2b078a2a20419c99d66fb2823173d73f188ce601"}, - {file = "charset_normalizer-3.4.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:fa88b843d6e211393a37219e6a1c1df99d35e8fd90446f1118f4216e307e48cd"}, - {file = "charset_normalizer-3.4.1-cp313-cp313-win32.whl", hash = "sha256:eb8178fe3dba6450a3e024e95ac49ed3400e506fd4e9e5c32d30adda88cbd407"}, - {file = "charset_normalizer-3.4.1-cp313-cp313-win_amd64.whl", hash = "sha256:b1ac5992a838106edb89654e0aebfc24f5848ae2547d22c2c3f66454daa11971"}, - {file = "charset_normalizer-3.4.1-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f30bf9fd9be89ecb2360c7d94a711f00c09b976258846efe40db3d05828e8089"}, - {file = "charset_normalizer-3.4.1-cp37-cp37m-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:97f68b8d6831127e4787ad15e6757232e14e12060bec17091b85eb1486b91d8d"}, - {file = "charset_normalizer-3.4.1-cp37-cp37m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:7974a0b5ecd505609e3b19742b60cee7aa2aa2fb3151bc917e6e2646d7667dcf"}, - {file = "charset_normalizer-3.4.1-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fc54db6c8593ef7d4b2a331b58653356cf04f67c960f584edb7c3d8c97e8f39e"}, - {file = "charset_normalizer-3.4.1-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:311f30128d7d333eebd7896965bfcfbd0065f1716ec92bd5638d7748eb6f936a"}, - {file = "charset_normalizer-3.4.1-cp37-cp37m-musllinux_1_2_aarch64.whl", hash = "sha256:7d053096f67cd1241601111b698f5cad775f97ab25d81567d3f59219b5f1adbd"}, - {file = "charset_normalizer-3.4.1-cp37-cp37m-musllinux_1_2_i686.whl", hash = "sha256:807f52c1f798eef6cf26beb819eeb8819b1622ddfeef9d0977a8502d4db6d534"}, - {file = "charset_normalizer-3.4.1-cp37-cp37m-musllinux_1_2_ppc64le.whl", hash = "sha256:dccbe65bd2f7f7ec22c4ff99ed56faa1e9f785482b9bbd7c717e26fd723a1d1e"}, - {file = "charset_normalizer-3.4.1-cp37-cp37m-musllinux_1_2_s390x.whl", hash = "sha256:2fb9bd477fdea8684f78791a6de97a953c51831ee2981f8e4f583ff3b9d9687e"}, - {file = "charset_normalizer-3.4.1-cp37-cp37m-musllinux_1_2_x86_64.whl", hash = "sha256:01732659ba9b5b873fc117534143e4feefecf3b2078b0a6a2e925271bb6f4cfa"}, - {file = "charset_normalizer-3.4.1-cp37-cp37m-win32.whl", hash = "sha256:7a4f97a081603d2050bfaffdefa5b02a9ec823f8348a572e39032caa8404a487"}, - {file = "charset_normalizer-3.4.1-cp37-cp37m-win_amd64.whl", hash = "sha256:7b1bef6280950ee6c177b326508f86cad7ad4dff12454483b51d8b7d673a2c5d"}, - {file = "charset_normalizer-3.4.1-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:ecddf25bee22fe4fe3737a399d0d177d72bc22be6913acfab364b40bce1ba83c"}, - {file = "charset_normalizer-3.4.1-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8c60ca7339acd497a55b0ea5d506b2a2612afb2826560416f6894e8b5770d4a9"}, - {file = "charset_normalizer-3.4.1-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b7b2d86dd06bfc2ade3312a83a5c364c7ec2e3498f8734282c6c3d4b07b346b8"}, - {file = "charset_normalizer-3.4.1-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:dd78cfcda14a1ef52584dbb008f7ac81c1328c0f58184bf9a84c49c605002da6"}, - {file = "charset_normalizer-3.4.1-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6e27f48bcd0957c6d4cb9d6fa6b61d192d0b13d5ef563e5f2ae35feafc0d179c"}, - {file = "charset_normalizer-3.4.1-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:01ad647cdd609225c5350561d084b42ddf732f4eeefe6e678765636791e78b9a"}, - {file = "charset_normalizer-3.4.1-cp38-cp38-musllinux_1_2_aarch64.whl", hash = "sha256:619a609aa74ae43d90ed2e89bdd784765de0a25ca761b93e196d938b8fd1dbbd"}, - {file = "charset_normalizer-3.4.1-cp38-cp38-musllinux_1_2_i686.whl", hash = "sha256:89149166622f4db9b4b6a449256291dc87a99ee53151c74cbd82a53c8c2f6ccd"}, - {file = "charset_normalizer-3.4.1-cp38-cp38-musllinux_1_2_ppc64le.whl", hash = "sha256:7709f51f5f7c853f0fb938bcd3bc59cdfdc5203635ffd18bf354f6967ea0f824"}, - {file = "charset_normalizer-3.4.1-cp38-cp38-musllinux_1_2_s390x.whl", hash = "sha256:345b0426edd4e18138d6528aed636de7a9ed169b4aaf9d61a8c19e39d26838ca"}, - {file = "charset_normalizer-3.4.1-cp38-cp38-musllinux_1_2_x86_64.whl", hash = "sha256:0907f11d019260cdc3f94fbdb23ff9125f6b5d1039b76003b5b0ac9d6a6c9d5b"}, - {file = "charset_normalizer-3.4.1-cp38-cp38-win32.whl", hash = "sha256:ea0d8d539afa5eb2728aa1932a988a9a7af94f18582ffae4bc10b3fbdad0626e"}, - {file = "charset_normalizer-3.4.1-cp38-cp38-win_amd64.whl", hash = "sha256:329ce159e82018d646c7ac45b01a430369d526569ec08516081727a20e9e4af4"}, - {file = "charset_normalizer-3.4.1-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:b97e690a2118911e39b4042088092771b4ae3fc3aa86518f84b8cf6888dbdb41"}, - {file = "charset_normalizer-3.4.1-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:78baa6d91634dfb69ec52a463534bc0df05dbd546209b79a3880a34487f4b84f"}, - {file = "charset_normalizer-3.4.1-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1a2bc9f351a75ef49d664206d51f8e5ede9da246602dc2d2726837620ea034b2"}, - {file = "charset_normalizer-3.4.1-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:75832c08354f595c760a804588b9357d34ec00ba1c940c15e31e96d902093770"}, - {file = "charset_normalizer-3.4.1-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0af291f4fe114be0280cdd29d533696a77b5b49cfde5467176ecab32353395c4"}, - {file = "charset_normalizer-3.4.1-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0167ddc8ab6508fe81860a57dd472b2ef4060e8d378f0cc555707126830f2537"}, - {file = "charset_normalizer-3.4.1-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:2a75d49014d118e4198bcee5ee0a6f25856b29b12dbf7cd012791f8a6cc5c496"}, - {file = "charset_normalizer-3.4.1-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:363e2f92b0f0174b2f8238240a1a30142e3db7b957a5dd5689b0e75fb717cc78"}, - {file = "charset_normalizer-3.4.1-cp39-cp39-musllinux_1_2_ppc64le.whl", hash = "sha256:ab36c8eb7e454e34e60eb55ca5d241a5d18b2c6244f6827a30e451c42410b5f7"}, - {file = "charset_normalizer-3.4.1-cp39-cp39-musllinux_1_2_s390x.whl", hash = "sha256:4c0907b1928a36d5a998d72d64d8eaa7244989f7aaaf947500d3a800c83a3fd6"}, - {file = "charset_normalizer-3.4.1-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:04432ad9479fa40ec0f387795ddad4437a2b50417c69fa275e212933519ff294"}, - {file = "charset_normalizer-3.4.1-cp39-cp39-win32.whl", hash = "sha256:3bed14e9c89dcb10e8f3a29f9ccac4955aebe93c71ae803af79265c9ca5644c5"}, - {file = "charset_normalizer-3.4.1-cp39-cp39-win_amd64.whl", hash = "sha256:49402233c892a461407c512a19435d1ce275543138294f7ef013f0b63d5d3765"}, - {file = "charset_normalizer-3.4.1-py3-none-any.whl", hash = "sha256:d98b1668f06378c6dbefec3b92299716b931cd4e6061f3c875a71ced1780ab85"}, - {file = "charset_normalizer-3.4.1.tar.gz", hash = "sha256:44251f18cd68a75b56585dd00dae26183e102cd5e0f9f1466e6df5da2ed64ea3"}, + {file = "charset_normalizer-3.4.2-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:7c48ed483eb946e6c04ccbe02c6b4d1d48e51944b6db70f697e089c193404941"}, + {file = "charset_normalizer-3.4.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b2d318c11350e10662026ad0eb71bb51c7812fc8590825304ae0bdd4ac283acd"}, + {file = "charset_normalizer-3.4.2-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9cbfacf36cb0ec2897ce0ebc5d08ca44213af24265bd56eca54bee7923c48fd6"}, + {file = "charset_normalizer-3.4.2-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:18dd2e350387c87dabe711b86f83c9c78af772c748904d372ade190b5c7c9d4d"}, + {file = "charset_normalizer-3.4.2-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8075c35cd58273fee266c58c0c9b670947c19df5fb98e7b66710e04ad4e9ff86"}, + {file = "charset_normalizer-3.4.2-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:5bf4545e3b962767e5c06fe1738f951f77d27967cb2caa64c28be7c4563e162c"}, + {file = "charset_normalizer-3.4.2-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:7a6ab32f7210554a96cd9e33abe3ddd86732beeafc7a28e9955cdf22ffadbab0"}, + {file = "charset_normalizer-3.4.2-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:b33de11b92e9f75a2b545d6e9b6f37e398d86c3e9e9653c4864eb7e89c5773ef"}, + {file = "charset_normalizer-3.4.2-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:8755483f3c00d6c9a77f490c17e6ab0c8729e39e6390328e42521ef175380ae6"}, + {file = "charset_normalizer-3.4.2-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:68a328e5f55ec37c57f19ebb1fdc56a248db2e3e9ad769919a58672958e8f366"}, + {file = "charset_normalizer-3.4.2-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:21b2899062867b0e1fde9b724f8aecb1af14f2778d69aacd1a5a1853a597a5db"}, + {file = "charset_normalizer-3.4.2-cp310-cp310-win32.whl", hash = "sha256:e8082b26888e2f8b36a042a58307d5b917ef2b1cacab921ad3323ef91901c71a"}, + {file = "charset_normalizer-3.4.2-cp310-cp310-win_amd64.whl", hash = "sha256:f69a27e45c43520f5487f27627059b64aaf160415589230992cec34c5e18a509"}, + {file = "charset_normalizer-3.4.2-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:be1e352acbe3c78727a16a455126d9ff83ea2dfdcbc83148d2982305a04714c2"}, + {file = "charset_normalizer-3.4.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:aa88ca0b1932e93f2d961bf3addbb2db902198dca337d88c89e1559e066e7645"}, + {file = "charset_normalizer-3.4.2-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d524ba3f1581b35c03cb42beebab4a13e6cdad7b36246bd22541fa585a56cccd"}, + {file = "charset_normalizer-3.4.2-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:28a1005facc94196e1fb3e82a3d442a9d9110b8434fc1ded7a24a2983c9888d8"}, + {file = "charset_normalizer-3.4.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fdb20a30fe1175ecabed17cbf7812f7b804b8a315a25f24678bcdf120a90077f"}, + {file = "charset_normalizer-3.4.2-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0f5d9ed7f254402c9e7d35d2f5972c9bbea9040e99cd2861bd77dc68263277c7"}, + {file = "charset_normalizer-3.4.2-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:efd387a49825780ff861998cd959767800d54f8308936b21025326de4b5a42b9"}, + {file = "charset_normalizer-3.4.2-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:f0aa37f3c979cf2546b73e8222bbfa3dc07a641585340179d768068e3455e544"}, + {file = "charset_normalizer-3.4.2-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:e70e990b2137b29dc5564715de1e12701815dacc1d056308e2b17e9095372a82"}, + {file = "charset_normalizer-3.4.2-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:0c8c57f84ccfc871a48a47321cfa49ae1df56cd1d965a09abe84066f6853b9c0"}, + {file = "charset_normalizer-3.4.2-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:6b66f92b17849b85cad91259efc341dce9c1af48e2173bf38a85c6329f1033e5"}, + {file = "charset_normalizer-3.4.2-cp311-cp311-win32.whl", hash = "sha256:daac4765328a919a805fa5e2720f3e94767abd632ae410a9062dff5412bae65a"}, + {file = "charset_normalizer-3.4.2-cp311-cp311-win_amd64.whl", hash = "sha256:e53efc7c7cee4c1e70661e2e112ca46a575f90ed9ae3fef200f2a25e954f4b28"}, + {file = "charset_normalizer-3.4.2-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:0c29de6a1a95f24b9a1aa7aefd27d2487263f00dfd55a77719b530788f75cff7"}, + {file = "charset_normalizer-3.4.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:cddf7bd982eaa998934a91f69d182aec997c6c468898efe6679af88283b498d3"}, + {file = "charset_normalizer-3.4.2-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:fcbe676a55d7445b22c10967bceaaf0ee69407fbe0ece4d032b6eb8d4565982a"}, + {file = "charset_normalizer-3.4.2-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d41c4d287cfc69060fa91cae9683eacffad989f1a10811995fa309df656ec214"}, + {file = "charset_normalizer-3.4.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4e594135de17ab3866138f496755f302b72157d115086d100c3f19370839dd3a"}, + {file = "charset_normalizer-3.4.2-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:cf713fe9a71ef6fd5adf7a79670135081cd4431c2943864757f0fa3a65b1fafd"}, + {file = "charset_normalizer-3.4.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:a370b3e078e418187da8c3674eddb9d983ec09445c99a3a263c2011993522981"}, + {file = "charset_normalizer-3.4.2-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:a955b438e62efdf7e0b7b52a64dc5c3396e2634baa62471768a64bc2adb73d5c"}, + {file = "charset_normalizer-3.4.2-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:7222ffd5e4de8e57e03ce2cef95a4c43c98fcb72ad86909abdfc2c17d227fc1b"}, + {file = "charset_normalizer-3.4.2-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:bee093bf902e1d8fc0ac143c88902c3dfc8941f7ea1d6a8dd2bcb786d33db03d"}, + {file = "charset_normalizer-3.4.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:dedb8adb91d11846ee08bec4c8236c8549ac721c245678282dcb06b221aab59f"}, + {file = "charset_normalizer-3.4.2-cp312-cp312-win32.whl", hash = "sha256:db4c7bf0e07fc3b7d89ac2a5880a6a8062056801b83ff56d8464b70f65482b6c"}, + {file = "charset_normalizer-3.4.2-cp312-cp312-win_amd64.whl", hash = "sha256:5a9979887252a82fefd3d3ed2a8e3b937a7a809f65dcb1e068b090e165bbe99e"}, + {file = "charset_normalizer-3.4.2-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:926ca93accd5d36ccdabd803392ddc3e03e6d4cd1cf17deff3b989ab8e9dbcf0"}, + {file = "charset_normalizer-3.4.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:eba9904b0f38a143592d9fc0e19e2df0fa2e41c3c3745554761c5f6447eedabf"}, + {file = "charset_normalizer-3.4.2-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3fddb7e2c84ac87ac3a947cb4e66d143ca5863ef48e4a5ecb83bd48619e4634e"}, + {file = "charset_normalizer-3.4.2-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:98f862da73774290f251b9df8d11161b6cf25b599a66baf087c1ffe340e9bfd1"}, + {file = "charset_normalizer-3.4.2-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6c9379d65defcab82d07b2a9dfbfc2e95bc8fe0ebb1b176a3190230a3ef0e07c"}, + {file = "charset_normalizer-3.4.2-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e635b87f01ebc977342e2697d05b56632f5f879a4f15955dfe8cef2448b51691"}, + {file = "charset_normalizer-3.4.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:1c95a1e2902a8b722868587c0e1184ad5c55631de5afc0eb96bc4b0d738092c0"}, + {file = "charset_normalizer-3.4.2-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:ef8de666d6179b009dce7bcb2ad4c4a779f113f12caf8dc77f0162c29d20490b"}, + {file = "charset_normalizer-3.4.2-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:32fc0341d72e0f73f80acb0a2c94216bd704f4f0bce10aedea38f30502b271ff"}, + {file = "charset_normalizer-3.4.2-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:289200a18fa698949d2b39c671c2cc7a24d44096784e76614899a7ccf2574b7b"}, + {file = "charset_normalizer-3.4.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:4a476b06fbcf359ad25d34a057b7219281286ae2477cc5ff5e3f70a246971148"}, + {file = "charset_normalizer-3.4.2-cp313-cp313-win32.whl", hash = "sha256:aaeeb6a479c7667fbe1099af9617c83aaca22182d6cf8c53966491a0f1b7ffb7"}, + {file = "charset_normalizer-3.4.2-cp313-cp313-win_amd64.whl", hash = "sha256:aa6af9e7d59f9c12b33ae4e9450619cf2488e2bbe9b44030905877f0b2324980"}, + {file = "charset_normalizer-3.4.2-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1cad5f45b3146325bb38d6855642f6fd609c3f7cad4dbaf75549bf3b904d3184"}, + {file = "charset_normalizer-3.4.2-cp37-cp37m-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b2680962a4848b3c4f155dc2ee64505a9c57186d0d56b43123b17ca3de18f0fa"}, + {file = "charset_normalizer-3.4.2-cp37-cp37m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:36b31da18b8890a76ec181c3cf44326bf2c48e36d393ca1b72b3f484113ea344"}, + {file = "charset_normalizer-3.4.2-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f4074c5a429281bf056ddd4c5d3b740ebca4d43ffffe2ef4bf4d2d05114299da"}, + {file = "charset_normalizer-3.4.2-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c9e36a97bee9b86ef9a1cf7bb96747eb7a15c2f22bdb5b516434b00f2a599f02"}, + {file = "charset_normalizer-3.4.2-cp37-cp37m-musllinux_1_2_aarch64.whl", hash = "sha256:1b1bde144d98e446b056ef98e59c256e9294f6b74d7af6846bf5ffdafd687a7d"}, + {file = "charset_normalizer-3.4.2-cp37-cp37m-musllinux_1_2_i686.whl", hash = "sha256:915f3849a011c1f593ab99092f3cecfcb4d65d8feb4a64cf1bf2d22074dc0ec4"}, + {file = "charset_normalizer-3.4.2-cp37-cp37m-musllinux_1_2_ppc64le.whl", hash = "sha256:fb707f3e15060adf5b7ada797624a6c6e0138e2a26baa089df64c68ee98e040f"}, + {file = "charset_normalizer-3.4.2-cp37-cp37m-musllinux_1_2_s390x.whl", hash = "sha256:25a23ea5c7edc53e0f29bae2c44fcb5a1aa10591aae107f2a2b2583a9c5cbc64"}, + {file = "charset_normalizer-3.4.2-cp37-cp37m-musllinux_1_2_x86_64.whl", hash = "sha256:770cab594ecf99ae64c236bc9ee3439c3f46be49796e265ce0cc8bc17b10294f"}, + {file = "charset_normalizer-3.4.2-cp37-cp37m-win32.whl", hash = "sha256:6a0289e4589e8bdfef02a80478f1dfcb14f0ab696b5a00e1f4b8a14a307a3c58"}, + {file = "charset_normalizer-3.4.2-cp37-cp37m-win_amd64.whl", hash = "sha256:6fc1f5b51fa4cecaa18f2bd7a003f3dd039dd615cd69a2afd6d3b19aed6775f2"}, + {file = "charset_normalizer-3.4.2-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:76af085e67e56c8816c3ccf256ebd136def2ed9654525348cfa744b6802b69eb"}, + {file = "charset_normalizer-3.4.2-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e45ba65510e2647721e35323d6ef54c7974959f6081b58d4ef5d87c60c84919a"}, + {file = "charset_normalizer-3.4.2-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:046595208aae0120559a67693ecc65dd75d46f7bf687f159127046628178dc45"}, + {file = "charset_normalizer-3.4.2-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:75d10d37a47afee94919c4fab4c22b9bc2a8bf7d4f46f87363bcf0573f3ff4f5"}, + {file = "charset_normalizer-3.4.2-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6333b3aa5a12c26b2a4d4e7335a28f1475e0e5e17d69d55141ee3cab736f66d1"}, + {file = "charset_normalizer-3.4.2-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e8323a9b031aa0393768b87f04b4164a40037fb2a3c11ac06a03ffecd3618027"}, + {file = "charset_normalizer-3.4.2-cp38-cp38-musllinux_1_2_aarch64.whl", hash = "sha256:24498ba8ed6c2e0b56d4acbf83f2d989720a93b41d712ebd4f4979660db4417b"}, + {file = "charset_normalizer-3.4.2-cp38-cp38-musllinux_1_2_i686.whl", hash = "sha256:844da2b5728b5ce0e32d863af26f32b5ce61bc4273a9c720a9f3aa9df73b1455"}, + {file = "charset_normalizer-3.4.2-cp38-cp38-musllinux_1_2_ppc64le.whl", hash = "sha256:65c981bdbd3f57670af8b59777cbfae75364b483fa8a9f420f08094531d54a01"}, + {file = "charset_normalizer-3.4.2-cp38-cp38-musllinux_1_2_s390x.whl", hash = "sha256:3c21d4fca343c805a52c0c78edc01e3477f6dd1ad7c47653241cf2a206d4fc58"}, + {file = "charset_normalizer-3.4.2-cp38-cp38-musllinux_1_2_x86_64.whl", hash = "sha256:dc7039885fa1baf9be153a0626e337aa7ec8bf96b0128605fb0d77788ddc1681"}, + {file = "charset_normalizer-3.4.2-cp38-cp38-win32.whl", hash = "sha256:8272b73e1c5603666618805fe821edba66892e2870058c94c53147602eab29c7"}, + {file = "charset_normalizer-3.4.2-cp38-cp38-win_amd64.whl", hash = "sha256:70f7172939fdf8790425ba31915bfbe8335030f05b9913d7ae00a87d4395620a"}, + {file = "charset_normalizer-3.4.2-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:005fa3432484527f9732ebd315da8da8001593e2cf46a3d817669f062c3d9ed4"}, + {file = "charset_normalizer-3.4.2-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e92fca20c46e9f5e1bb485887d074918b13543b1c2a1185e69bb8d17ab6236a7"}, + {file = "charset_normalizer-3.4.2-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:50bf98d5e563b83cc29471fa114366e6806bc06bc7a25fd59641e41445327836"}, + {file = "charset_normalizer-3.4.2-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:721c76e84fe669be19c5791da68232ca2e05ba5185575086e384352e2c309597"}, + {file = "charset_normalizer-3.4.2-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:82d8fd25b7f4675d0c47cf95b594d4e7b158aca33b76aa63d07186e13c0e0ab7"}, + {file = "charset_normalizer-3.4.2-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b3daeac64d5b371dea99714f08ffc2c208522ec6b06fbc7866a450dd446f5c0f"}, + {file = "charset_normalizer-3.4.2-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:dccab8d5fa1ef9bfba0590ecf4d46df048d18ffe3eec01eeb73a42e0d9e7a8ba"}, + {file = "charset_normalizer-3.4.2-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:aaf27faa992bfee0264dc1f03f4c75e9fcdda66a519db6b957a3f826e285cf12"}, + {file = "charset_normalizer-3.4.2-cp39-cp39-musllinux_1_2_ppc64le.whl", hash = "sha256:eb30abc20df9ab0814b5a2524f23d75dcf83cde762c161917a2b4b7b55b1e518"}, + {file = "charset_normalizer-3.4.2-cp39-cp39-musllinux_1_2_s390x.whl", hash = "sha256:c72fbbe68c6f32f251bdc08b8611c7b3060612236e960ef848e0a517ddbe76c5"}, + {file = "charset_normalizer-3.4.2-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:982bb1e8b4ffda883b3d0a521e23abcd6fd17418f6d2c4118d257a10199c0ce3"}, + {file = "charset_normalizer-3.4.2-cp39-cp39-win32.whl", hash = "sha256:43e0933a0eff183ee85833f341ec567c0980dae57c464d8a508e1b2ceb336471"}, + {file = "charset_normalizer-3.4.2-cp39-cp39-win_amd64.whl", hash = "sha256:d11b54acf878eef558599658b0ffca78138c8c3655cf4f3a4a673c437e67732e"}, + {file = "charset_normalizer-3.4.2-py3-none-any.whl", hash = "sha256:7f56930ab0abd1c45cd15be65cc741c28b1c9a34876ce8c17a2fa107810c0af0"}, + {file = "charset_normalizer-3.4.2.tar.gz", hash = "sha256:5baececa9ecba31eff645232d59845c07aa030f0c81ee70184a90d35099a0e63"}, ] [[package]] @@ -329,75 +329,100 @@ files = [ [[package]] name = "coverage" -version = "7.8.0" +version = "7.10.1" description = "Code coverage measurement for Python" optional = false python-versions = ">=3.9" groups = ["dev"] files = [ - {file = "coverage-7.8.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:2931f66991175369859b5fd58529cd4b73582461877ecfd859b6549869287ffe"}, - {file = "coverage-7.8.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:52a523153c568d2c0ef8826f6cc23031dc86cffb8c6aeab92c4ff776e7951b28"}, - {file = "coverage-7.8.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5c8a5c139aae4c35cbd7cadca1df02ea8cf28a911534fc1b0456acb0b14234f3"}, - {file = "coverage-7.8.0-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:5a26c0c795c3e0b63ec7da6efded5f0bc856d7c0b24b2ac84b4d1d7bc578d676"}, - {file = "coverage-7.8.0-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:821f7bcbaa84318287115d54becb1915eece6918136c6f91045bb84e2f88739d"}, - {file = "coverage-7.8.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:a321c61477ff8ee705b8a5fed370b5710c56b3a52d17b983d9215861e37b642a"}, - {file = "coverage-7.8.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:ed2144b8a78f9d94d9515963ed273d620e07846acd5d4b0a642d4849e8d91a0c"}, - {file = "coverage-7.8.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:042e7841a26498fff7a37d6fda770d17519982f5b7d8bf5278d140b67b61095f"}, - {file = "coverage-7.8.0-cp310-cp310-win32.whl", hash = "sha256:f9983d01d7705b2d1f7a95e10bbe4091fabc03a46881a256c2787637b087003f"}, - {file = "coverage-7.8.0-cp310-cp310-win_amd64.whl", hash = "sha256:5a570cd9bd20b85d1a0d7b009aaf6c110b52b5755c17be6962f8ccd65d1dbd23"}, - {file = "coverage-7.8.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:e7ac22a0bb2c7c49f441f7a6d46c9c80d96e56f5a8bc6972529ed43c8b694e27"}, - {file = "coverage-7.8.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:bf13d564d310c156d1c8e53877baf2993fb3073b2fc9f69790ca6a732eb4bfea"}, - {file = "coverage-7.8.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a5761c70c017c1b0d21b0815a920ffb94a670c8d5d409d9b38857874c21f70d7"}, - {file = "coverage-7.8.0-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e5ff52d790c7e1628241ffbcaeb33e07d14b007b6eb00a19320c7b8a7024c040"}, - {file = "coverage-7.8.0-cp311-cp311-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d39fc4817fd67b3915256af5dda75fd4ee10621a3d484524487e33416c6f3543"}, - {file = "coverage-7.8.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:b44674870709017e4b4036e3d0d6c17f06a0e6d4436422e0ad29b882c40697d2"}, - {file = "coverage-7.8.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:8f99eb72bf27cbb167b636eb1726f590c00e1ad375002230607a844d9e9a2318"}, - {file = "coverage-7.8.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:b571bf5341ba8c6bc02e0baeaf3b061ab993bf372d982ae509807e7f112554e9"}, - {file = "coverage-7.8.0-cp311-cp311-win32.whl", hash = "sha256:e75a2ad7b647fd8046d58c3132d7eaf31b12d8a53c0e4b21fa9c4d23d6ee6d3c"}, - {file = "coverage-7.8.0-cp311-cp311-win_amd64.whl", hash = "sha256:3043ba1c88b2139126fc72cb48574b90e2e0546d4c78b5299317f61b7f718b78"}, - {file = "coverage-7.8.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:bbb5cc845a0292e0c520656d19d7ce40e18d0e19b22cb3e0409135a575bf79fc"}, - {file = "coverage-7.8.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:4dfd9a93db9e78666d178d4f08a5408aa3f2474ad4d0e0378ed5f2ef71640cb6"}, - {file = "coverage-7.8.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f017a61399f13aa6d1039f75cd467be388d157cd81f1a119b9d9a68ba6f2830d"}, - {file = "coverage-7.8.0-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0915742f4c82208ebf47a2b154a5334155ed9ef9fe6190674b8a46c2fb89cb05"}, - {file = "coverage-7.8.0-cp312-cp312-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8a40fcf208e021eb14b0fac6bdb045c0e0cab53105f93ba0d03fd934c956143a"}, - {file = "coverage-7.8.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:a1f406a8e0995d654b2ad87c62caf6befa767885301f3b8f6f73e6f3c31ec3a6"}, - {file = "coverage-7.8.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:77af0f6447a582fdc7de5e06fa3757a3ef87769fbb0fdbdeba78c23049140a47"}, - {file = "coverage-7.8.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:f2d32f95922927186c6dbc8bc60df0d186b6edb828d299ab10898ef3f40052fe"}, - {file = "coverage-7.8.0-cp312-cp312-win32.whl", hash = "sha256:769773614e676f9d8e8a0980dd7740f09a6ea386d0f383db6821df07d0f08545"}, - {file = "coverage-7.8.0-cp312-cp312-win_amd64.whl", hash = "sha256:e5d2b9be5b0693cf21eb4ce0ec8d211efb43966f6657807f6859aab3814f946b"}, - {file = "coverage-7.8.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:5ac46d0c2dd5820ce93943a501ac5f6548ea81594777ca585bf002aa8854cacd"}, - {file = "coverage-7.8.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:771eb7587a0563ca5bb6f622b9ed7f9d07bd08900f7589b4febff05f469bea00"}, - {file = "coverage-7.8.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:42421e04069fb2cbcbca5a696c4050b84a43b05392679d4068acbe65449b5c64"}, - {file = "coverage-7.8.0-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:554fec1199d93ab30adaa751db68acec2b41c5602ac944bb19187cb9a41a8067"}, - {file = "coverage-7.8.0-cp313-cp313-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5aaeb00761f985007b38cf463b1d160a14a22c34eb3f6a39d9ad6fc27cb73008"}, - {file = "coverage-7.8.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:581a40c7b94921fffd6457ffe532259813fc68eb2bdda60fa8cc343414ce3733"}, - {file = "coverage-7.8.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:f319bae0321bc838e205bf9e5bc28f0a3165f30c203b610f17ab5552cff90323"}, - {file = "coverage-7.8.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:04bfec25a8ef1c5f41f5e7e5c842f6b615599ca8ba8391ec33a9290d9d2db3a3"}, - {file = "coverage-7.8.0-cp313-cp313-win32.whl", hash = "sha256:dd19608788b50eed889e13a5d71d832edc34fc9dfce606f66e8f9f917eef910d"}, - {file = "coverage-7.8.0-cp313-cp313-win_amd64.whl", hash = "sha256:a9abbccd778d98e9c7e85038e35e91e67f5b520776781d9a1e2ee9d400869487"}, - {file = "coverage-7.8.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:18c5ae6d061ad5b3e7eef4363fb27a0576012a7447af48be6c75b88494c6cf25"}, - {file = "coverage-7.8.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:95aa6ae391a22bbbce1b77ddac846c98c5473de0372ba5c463480043a07bff42"}, - {file = "coverage-7.8.0-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e013b07ba1c748dacc2a80e69a46286ff145935f260eb8c72df7185bf048f502"}, - {file = "coverage-7.8.0-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d766a4f0e5aa1ba056ec3496243150698dc0481902e2b8559314368717be82b1"}, - {file = "coverage-7.8.0-cp313-cp313t-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ad80e6b4a0c3cb6f10f29ae4c60e991f424e6b14219d46f1e7d442b938ee68a4"}, - {file = "coverage-7.8.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:b87eb6fc9e1bb8f98892a2458781348fa37e6925f35bb6ceb9d4afd54ba36c73"}, - {file = "coverage-7.8.0-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:d1ba00ae33be84066cfbe7361d4e04dec78445b2b88bdb734d0d1cbab916025a"}, - {file = "coverage-7.8.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:f3c38e4e5ccbdc9198aecc766cedbb134b2d89bf64533973678dfcf07effd883"}, - {file = "coverage-7.8.0-cp313-cp313t-win32.whl", hash = "sha256:379fe315e206b14e21db5240f89dc0774bdd3e25c3c58c2c733c99eca96f1ada"}, - {file = "coverage-7.8.0-cp313-cp313t-win_amd64.whl", hash = "sha256:2e4b6b87bb0c846a9315e3ab4be2d52fac905100565f4b92f02c445c8799e257"}, - {file = "coverage-7.8.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:fa260de59dfb143af06dcf30c2be0b200bed2a73737a8a59248fcb9fa601ef0f"}, - {file = "coverage-7.8.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:96121edfa4c2dfdda409877ea8608dd01de816a4dc4a0523356067b305e4e17a"}, - {file = "coverage-7.8.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6b8af63b9afa1031c0ef05b217faa598f3069148eeee6bb24b79da9012423b82"}, - {file = "coverage-7.8.0-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:89b1f4af0d4afe495cd4787a68e00f30f1d15939f550e869de90a86efa7e0814"}, - {file = "coverage-7.8.0-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:94ec0be97723ae72d63d3aa41961a0b9a6f5a53ff599813c324548d18e3b9e8c"}, - {file = "coverage-7.8.0-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:8a1d96e780bdb2d0cbb297325711701f7c0b6f89199a57f2049e90064c29f6bd"}, - {file = "coverage-7.8.0-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:f1d8a2a57b47142b10374902777e798784abf400a004b14f1b0b9eaf1e528ba4"}, - {file = "coverage-7.8.0-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:cf60dd2696b457b710dd40bf17ad269d5f5457b96442f7f85722bdb16fa6c899"}, - {file = "coverage-7.8.0-cp39-cp39-win32.whl", hash = "sha256:be945402e03de47ba1872cd5236395e0f4ad635526185a930735f66710e1bd3f"}, - {file = "coverage-7.8.0-cp39-cp39-win_amd64.whl", hash = "sha256:90e7fbc6216ecaffa5a880cdc9c77b7418c1dcb166166b78dbc630d07f278cc3"}, - {file = "coverage-7.8.0-pp39.pp310.pp311-none-any.whl", hash = "sha256:b8194fb8e50d556d5849753de991d390c5a1edeeba50f68e3a9253fbd8bf8ccd"}, - {file = "coverage-7.8.0-py3-none-any.whl", hash = "sha256:dbf364b4c5e7bae9250528167dfe40219b62e2d573c854d74be213e1e52069f7"}, - {file = "coverage-7.8.0.tar.gz", hash = "sha256:7a3d62b3b03b4b6fd41a085f3574874cf946cb4604d2b4d3e8dca8cd570ca501"}, + {file = "coverage-7.10.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:1c86eb388bbd609d15560e7cc0eb936c102b6f43f31cf3e58b4fd9afe28e1372"}, + {file = "coverage-7.10.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:6b4ba0f488c1bdb6bd9ba81da50715a372119785458831c73428a8566253b86b"}, + {file = "coverage-7.10.1-cp310-cp310-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:083442ecf97d434f0cb3b3e3676584443182653da08b42e965326ba12d6b5f2a"}, + {file = "coverage-7.10.1-cp310-cp310-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:c1a40c486041006b135759f59189385da7c66d239bad897c994e18fd1d0c128f"}, + {file = "coverage-7.10.1-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3beb76e20b28046989300c4ea81bf690df84ee98ade4dc0bbbf774a28eb98440"}, + {file = "coverage-7.10.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:bc265a7945e8d08da28999ad02b544963f813a00f3ed0a7a0ce4165fd77629f8"}, + {file = "coverage-7.10.1-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:47c91f32ba4ac46f1e224a7ebf3f98b4b24335bad16137737fe71a5961a0665c"}, + {file = "coverage-7.10.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:1a108dd78ed185020f66f131c60078f3fae3f61646c28c8bb4edd3fa121fc7fc"}, + {file = "coverage-7.10.1-cp310-cp310-win32.whl", hash = "sha256:7092cc82382e634075cc0255b0b69cb7cada7c1f249070ace6a95cb0f13548ef"}, + {file = "coverage-7.10.1-cp310-cp310-win_amd64.whl", hash = "sha256:ac0c5bba938879c2fc0bc6c1b47311b5ad1212a9dcb8b40fe2c8110239b7faed"}, + {file = "coverage-7.10.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:b45e2f9d5b0b5c1977cb4feb5f594be60eb121106f8900348e29331f553a726f"}, + {file = "coverage-7.10.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:3a7a4d74cb0f5e3334f9aa26af7016ddb94fb4bfa11b4a573d8e98ecba8c34f1"}, + {file = "coverage-7.10.1-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:d4b0aab55ad60ead26159ff12b538c85fbab731a5e3411c642b46c3525863437"}, + {file = "coverage-7.10.1-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:dcc93488c9ebd229be6ee1f0d9aad90da97b33ad7e2912f5495804d78a3cd6b7"}, + {file = "coverage-7.10.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:aa309df995d020f3438407081b51ff527171cca6772b33cf8f85344b8b4b8770"}, + {file = "coverage-7.10.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:cfb8b9d8855c8608f9747602a48ab525b1d320ecf0113994f6df23160af68262"}, + {file = "coverage-7.10.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:320d86da829b012982b414c7cdda65f5d358d63f764e0e4e54b33097646f39a3"}, + {file = "coverage-7.10.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:dc60ddd483c556590da1d9482a4518292eec36dd0e1e8496966759a1f282bcd0"}, + {file = "coverage-7.10.1-cp311-cp311-win32.whl", hash = "sha256:4fcfe294f95b44e4754da5b58be750396f2b1caca8f9a0e78588e3ef85f8b8be"}, + {file = "coverage-7.10.1-cp311-cp311-win_amd64.whl", hash = "sha256:efa23166da3fe2915f8ab452dde40319ac84dc357f635737174a08dbd912980c"}, + {file = "coverage-7.10.1-cp311-cp311-win_arm64.whl", hash = "sha256:d12b15a8c3759e2bb580ffa423ae54be4f184cf23beffcbd641f4fe6e1584293"}, + {file = "coverage-7.10.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:6b7dc7f0a75a7eaa4584e5843c873c561b12602439d2351ee28c7478186c4da4"}, + {file = "coverage-7.10.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:607f82389f0ecafc565813aa201a5cade04f897603750028dd660fb01797265e"}, + {file = "coverage-7.10.1-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:f7da31a1ba31f1c1d4d5044b7c5813878adae1f3af8f4052d679cc493c7328f4"}, + {file = "coverage-7.10.1-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:51fe93f3fe4f5d8483d51072fddc65e717a175490804e1942c975a68e04bf97a"}, + {file = "coverage-7.10.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3e59d00830da411a1feef6ac828b90bbf74c9b6a8e87b8ca37964925bba76dbe"}, + {file = "coverage-7.10.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:924563481c27941229cb4e16eefacc35da28563e80791b3ddc5597b062a5c386"}, + {file = "coverage-7.10.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:ca79146ee421b259f8131f153102220b84d1a5e6fb9c8aed13b3badfd1796de6"}, + {file = "coverage-7.10.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:2b225a06d227f23f386fdc0eab471506d9e644be699424814acc7d114595495f"}, + {file = "coverage-7.10.1-cp312-cp312-win32.whl", hash = "sha256:5ba9a8770effec5baaaab1567be916c87d8eea0c9ad11253722d86874d885eca"}, + {file = "coverage-7.10.1-cp312-cp312-win_amd64.whl", hash = "sha256:9eb245a8d8dd0ad73b4062135a251ec55086fbc2c42e0eb9725a9b553fba18a3"}, + {file = "coverage-7.10.1-cp312-cp312-win_arm64.whl", hash = "sha256:7718060dd4434cc719803a5e526838a5d66e4efa5dc46d2b25c21965a9c6fcc4"}, + {file = "coverage-7.10.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:ebb08d0867c5a25dffa4823377292a0ffd7aaafb218b5d4e2e106378b1061e39"}, + {file = "coverage-7.10.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:f32a95a83c2e17422f67af922a89422cd24c6fa94041f083dd0bb4f6057d0bc7"}, + {file = "coverage-7.10.1-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:c4c746d11c8aba4b9f58ca8bfc6fbfd0da4efe7960ae5540d1a1b13655ee8892"}, + {file = "coverage-7.10.1-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:7f39edd52c23e5c7ed94e0e4bf088928029edf86ef10b95413e5ea670c5e92d7"}, + {file = "coverage-7.10.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ab6e19b684981d0cd968906e293d5628e89faacb27977c92f3600b201926b994"}, + {file = "coverage-7.10.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:5121d8cf0eacb16133501455d216bb5f99899ae2f52d394fe45d59229e6611d0"}, + {file = "coverage-7.10.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:df1c742ca6f46a6f6cbcaef9ac694dc2cb1260d30a6a2f5c68c5f5bcfee1cfd7"}, + {file = "coverage-7.10.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:40f9a38676f9c073bf4b9194707aa1eb97dca0e22cc3766d83879d72500132c7"}, + {file = "coverage-7.10.1-cp313-cp313-win32.whl", hash = "sha256:2348631f049e884839553b9974f0821d39241c6ffb01a418efce434f7eba0fe7"}, + {file = "coverage-7.10.1-cp313-cp313-win_amd64.whl", hash = "sha256:4072b31361b0d6d23f750c524f694e1a417c1220a30d3ef02741eed28520c48e"}, + {file = "coverage-7.10.1-cp313-cp313-win_arm64.whl", hash = "sha256:3e31dfb8271937cab9425f19259b1b1d1f556790e98eb266009e7a61d337b6d4"}, + {file = "coverage-7.10.1-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:1c4f679c6b573a5257af6012f167a45be4c749c9925fd44d5178fd641ad8bf72"}, + {file = "coverage-7.10.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:871ebe8143da284bd77b84a9136200bd638be253618765d21a1fce71006d94af"}, + {file = "coverage-7.10.1-cp313-cp313t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:998c4751dabf7d29b30594af416e4bf5091f11f92a8d88eb1512c7ba136d1ed7"}, + {file = "coverage-7.10.1-cp313-cp313t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:780f750a25e7749d0af6b3631759c2c14f45de209f3faaa2398312d1c7a22759"}, + {file = "coverage-7.10.1-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:590bdba9445df4763bdbebc928d8182f094c1f3947a8dc0fc82ef014dbdd8324"}, + {file = "coverage-7.10.1-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:9b2df80cb6a2af86d300e70acb82e9b79dab2c1e6971e44b78dbfc1a1e736b53"}, + {file = "coverage-7.10.1-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:d6a558c2725bfb6337bf57c1cd366c13798bfd3bfc9e3dd1f4a6f6fc95a4605f"}, + {file = "coverage-7.10.1-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:e6150d167f32f2a54690e572e0a4c90296fb000a18e9b26ab81a6489e24e78dd"}, + {file = "coverage-7.10.1-cp313-cp313t-win32.whl", hash = "sha256:d946a0c067aa88be4a593aad1236493313bafaa27e2a2080bfe88db827972f3c"}, + {file = "coverage-7.10.1-cp313-cp313t-win_amd64.whl", hash = "sha256:e37c72eaccdd5ed1130c67a92ad38f5b2af66eeff7b0abe29534225db2ef7b18"}, + {file = "coverage-7.10.1-cp313-cp313t-win_arm64.whl", hash = "sha256:89ec0ffc215c590c732918c95cd02b55c7d0f569d76b90bb1a5e78aa340618e4"}, + {file = "coverage-7.10.1-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:166d89c57e877e93d8827dac32cedae6b0277ca684c6511497311249f35a280c"}, + {file = "coverage-7.10.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:bed4a2341b33cd1a7d9ffc47df4a78ee61d3416d43b4adc9e18b7d266650b83e"}, + {file = "coverage-7.10.1-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:ddca1e4f5f4c67980533df01430184c19b5359900e080248bbf4ed6789584d8b"}, + {file = "coverage-7.10.1-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:37b69226001d8b7de7126cad7366b0778d36777e4d788c66991455ba817c5b41"}, + {file = "coverage-7.10.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b2f22102197bcb1722691296f9e589f02b616f874e54a209284dd7b9294b0b7f"}, + {file = "coverage-7.10.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:1e0c768b0f9ac5839dac5cf88992a4bb459e488ee8a1f8489af4cb33b1af00f1"}, + {file = "coverage-7.10.1-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:991196702d5e0b120a8fef2664e1b9c333a81d36d5f6bcf6b225c0cf8b0451a2"}, + {file = "coverage-7.10.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:ae8e59e5f4fd85d6ad34c2bb9d74037b5b11be072b8b7e9986beb11f957573d4"}, + {file = "coverage-7.10.1-cp314-cp314-win32.whl", hash = "sha256:042125c89cf74a074984002e165d61fe0e31c7bd40ebb4bbebf07939b5924613"}, + {file = "coverage-7.10.1-cp314-cp314-win_amd64.whl", hash = "sha256:a22c3bfe09f7a530e2c94c87ff7af867259c91bef87ed2089cd69b783af7b84e"}, + {file = "coverage-7.10.1-cp314-cp314-win_arm64.whl", hash = "sha256:ee6be07af68d9c4fca4027c70cea0c31a0f1bc9cb464ff3c84a1f916bf82e652"}, + {file = "coverage-7.10.1-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:d24fb3c0c8ff0d517c5ca5de7cf3994a4cd559cde0315201511dbfa7ab528894"}, + {file = "coverage-7.10.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:1217a54cfd79be20512a67ca81c7da3f2163f51bbfd188aab91054df012154f5"}, + {file = "coverage-7.10.1-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:51f30da7a52c009667e02f125737229d7d8044ad84b79db454308033a7808ab2"}, + {file = "coverage-7.10.1-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:ed3718c757c82d920f1c94089066225ca2ad7f00bb904cb72b1c39ebdd906ccb"}, + {file = "coverage-7.10.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:cc452481e124a819ced0c25412ea2e144269ef2f2534b862d9f6a9dae4bda17b"}, + {file = "coverage-7.10.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:9d6f494c307e5cb9b1e052ec1a471060f1dea092c8116e642e7a23e79d9388ea"}, + {file = "coverage-7.10.1-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:fc0e46d86905ddd16b85991f1f4919028092b4e511689bbdaff0876bd8aab3dd"}, + {file = "coverage-7.10.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:80b9ccd82e30038b61fc9a692a8dc4801504689651b281ed9109f10cc9fe8b4d"}, + {file = "coverage-7.10.1-cp314-cp314t-win32.whl", hash = "sha256:e58991a2b213417285ec866d3cd32db17a6a88061a985dbb7e8e8f13af429c47"}, + {file = "coverage-7.10.1-cp314-cp314t-win_amd64.whl", hash = "sha256:e88dd71e4ecbc49d9d57d064117462c43f40a21a1383507811cf834a4a620651"}, + {file = "coverage-7.10.1-cp314-cp314t-win_arm64.whl", hash = "sha256:1aadfb06a30c62c2eb82322171fe1f7c288c80ca4156d46af0ca039052814bab"}, + {file = "coverage-7.10.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:57b6e8789cbefdef0667e4a94f8ffa40f9402cee5fc3b8e4274c894737890145"}, + {file = "coverage-7.10.1-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:85b22a9cce00cb03156334da67eb86e29f22b5e93876d0dd6a98646bb8a74e53"}, + {file = "coverage-7.10.1-cp39-cp39-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:97b6983a2f9c76d345ca395e843a049390b39652984e4a3b45b2442fa733992d"}, + {file = "coverage-7.10.1-cp39-cp39-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:ddf2a63b91399a1c2f88f40bc1705d5a7777e31c7e9eb27c602280f477b582ba"}, + {file = "coverage-7.10.1-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:47ab6dbbc31a14c5486420c2c1077fcae692097f673cf5be9ddbec8cdaa4cdbc"}, + {file = "coverage-7.10.1-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:21eb7d8b45d3700e7c2936a736f732794c47615a20f739f4133d5230a6512a88"}, + {file = "coverage-7.10.1-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:283005bb4d98ae33e45f2861cd2cde6a21878661c9ad49697f6951b358a0379b"}, + {file = "coverage-7.10.1-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:fefe31d61d02a8b2c419700b1fade9784a43d726de26495f243b663cd9fe1513"}, + {file = "coverage-7.10.1-cp39-cp39-win32.whl", hash = "sha256:e8ab8e4c7ec7f8a55ac05b5b715a051d74eac62511c6d96d5bb79aaafa3b04cf"}, + {file = "coverage-7.10.1-cp39-cp39-win_amd64.whl", hash = "sha256:c36baa0ecde742784aa76c2b816466d3ea888d5297fda0edbac1bf48fa94688a"}, + {file = "coverage-7.10.1-py3-none-any.whl", hash = "sha256:fa2a258aa6bf188eb9a8948f7102a83da7c430a0dce918dbd8b60ef8fcb772d7"}, + {file = "coverage-7.10.1.tar.gz", hash = "sha256:ae2b4856f29ddfe827106794f3589949a57da6f0d38ab01e24ec35107979ba57"}, ] [package.dependencies] @@ -459,59 +484,62 @@ test-randomorder = ["pytest-randomly"] [[package]] name = "docutils" -version = "0.21.2" +version = "0.22" description = "Docutils -- Python Documentation Utilities" optional = false python-versions = ">=3.9" groups = ["dev"] files = [ - {file = "docutils-0.21.2-py3-none-any.whl", hash = "sha256:dafca5b9e384f0e419294eb4d2ff9fa826435bf15f15b7bd45723e8ad76811b2"}, - {file = "docutils-0.21.2.tar.gz", hash = "sha256:3a6b18732edf182daa3cd12775bbb338cf5691468f91eeeb109deff6ebfa986f"}, + {file = "docutils-0.22-py3-none-any.whl", hash = "sha256:4ed966a0e96a0477d852f7af31bdcb3adc049fbb35ccba358c2ea8a03287615e"}, + {file = "docutils-0.22.tar.gz", hash = "sha256:ba9d57750e92331ebe7c08a1bbf7a7f8143b86c476acd51528b042216a6aad0f"}, ] [[package]] name = "exceptiongroup" -version = "1.2.2" +version = "1.3.0" description = "Backport of PEP 654 (exception groups)" optional = false python-versions = ">=3.7" groups = ["main", "dev"] markers = "python_version < \"3.11\"" files = [ - {file = "exceptiongroup-1.2.2-py3-none-any.whl", hash = "sha256:3111b9d131c238bec2f8f516e123e14ba243563fb135d3fe885990585aa7795b"}, - {file = "exceptiongroup-1.2.2.tar.gz", hash = "sha256:47c2edf7c6738fafb49fd34290706d1a1a2f4d1c6df275526b62cbb4aa5393cc"}, + {file = "exceptiongroup-1.3.0-py3-none-any.whl", hash = "sha256:4d111e6e0c13d0644cad6ddaa7ed0261a0b36971f6d23e7ec9b4b9097da78a10"}, + {file = "exceptiongroup-1.3.0.tar.gz", hash = "sha256:b241f5885f560bc56a59ee63ca4c6a8bfa46ae4ad651af316d4e81817bb9fd88"}, ] +[package.dependencies] +typing-extensions = {version = ">=4.6.0", markers = "python_version < \"3.13\""} + [package.extras] test = ["pytest (>=6)"] [[package]] name = "h11" -version = "0.14.0" +version = "0.16.0" description = "A pure-Python, bring-your-own-I/O implementation of HTTP/1.1" optional = false -python-versions = ">=3.7" +python-versions = ">=3.8" groups = ["main", "dev"] files = [ - {file = "h11-0.14.0-py3-none-any.whl", hash = "sha256:e3fe4ac4b851c468cc8363d500db52c2ead036020723024a109d37346efaa761"}, - {file = "h11-0.14.0.tar.gz", hash = "sha256:8f19fbbe99e72420ff35c00b27a34cb9937e902a8b810e2c88300c6f0a3b699d"}, + {file = "h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86"}, + {file = "h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1"}, ] [[package]] name = "httpcore" -version = "1.0.7" +version = "1.0.9" description = "A minimal low-level HTTP client." optional = false python-versions = ">=3.8" groups = ["main", "dev"] files = [ - {file = "httpcore-1.0.7-py3-none-any.whl", hash = "sha256:a3fff8f43dc260d5bd363d9f9cf1830fa3a458b332856f34282de498ed420edd"}, - {file = "httpcore-1.0.7.tar.gz", hash = "sha256:8551cb62a169ec7162ac7be8d4817d561f60e08eaa485234898414bb5a8a0b4c"}, + {file = "httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55"}, + {file = "httpcore-1.0.9.tar.gz", hash = "sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8"}, ] [package.dependencies] certifi = "*" -h11 = ">=0.13,<0.15" +h11 = ">=0.16" [package.extras] asyncio = ["anyio (>=4.0,<5.0)"] @@ -581,15 +609,15 @@ all = ["flake8 (>=7.1.1)", "mypy (>=1.11.2)", "pytest (>=8.3.2)", "ruff (>=0.6.2 [[package]] name = "importlib-metadata" -version = "8.6.1" +version = "8.7.0" description = "Read metadata from Python packages" optional = false python-versions = ">=3.9" groups = ["dev"] markers = "platform_machine != \"ppc64le\" and platform_machine != \"s390x\" and python_version < \"3.12\" or python_version == \"3.9\"" files = [ - {file = "importlib_metadata-8.6.1-py3-none-any.whl", hash = "sha256:02a89390c1e15fdfdc0d7c6b25cb3e62650d0494005c97d6f148bf5b9787525e"}, - {file = "importlib_metadata-8.6.1.tar.gz", hash = "sha256:310b41d755445d74569f993ccfc22838295d9fe005425094fad953d7f15c8580"}, + {file = "importlib_metadata-8.7.0-py3-none-any.whl", hash = "sha256:e5dd1551894c77868a30651cef00984d50e1002d06942a7101d34870c5f02afd"}, + {file = "importlib_metadata-8.7.0.tar.gz", hash = "sha256:d13b81ad223b890aa16c5471f2ac3056cf76c5f10f82d6f9292f0b415f389000"}, ] [package.dependencies] @@ -658,19 +686,19 @@ test = ["portend", "pytest (>=6,!=8.1.*)", "pytest-checkdocs (>=2.4)", "pytest-c [[package]] name = "jaraco-functools" -version = "4.1.0" +version = "4.2.1" description = "Functools like those found in stdlib" optional = false -python-versions = ">=3.8" +python-versions = ">=3.9" groups = ["dev"] markers = "platform_machine != \"ppc64le\" and platform_machine != \"s390x\"" files = [ - {file = "jaraco.functools-4.1.0-py3-none-any.whl", hash = "sha256:ad159f13428bc4acbf5541ad6dec511f91573b90fba04df61dafa2a1231cf649"}, - {file = "jaraco_functools-4.1.0.tar.gz", hash = "sha256:70f7e0e2ae076498e212562325e805204fc092d7b4c17e0e86c959e249701a9d"}, + {file = "jaraco_functools-4.2.1-py3-none-any.whl", hash = "sha256:590486285803805f4b1f99c60ca9e94ed348d4added84b74c7a12885561e524e"}, + {file = "jaraco_functools-4.2.1.tar.gz", hash = "sha256:be634abfccabce56fa3053f8c7ebe37b682683a4ee7793670ced17bab0087353"}, ] [package.dependencies] -more-itertools = "*" +more_itertools = "*" [package.extras] check = ["pytest-checkdocs (>=2.4)", "pytest-ruff (>=0.2.1) ; sys_platform != \"cygwin\""] @@ -767,78 +795,80 @@ files = [ [[package]] name = "more-itertools" -version = "10.6.0" +version = "10.7.0" description = "More routines for operating on iterables, beyond itertools" optional = false python-versions = ">=3.9" groups = ["dev"] markers = "platform_machine != \"ppc64le\" and platform_machine != \"s390x\"" files = [ - {file = "more-itertools-10.6.0.tar.gz", hash = "sha256:2cd7fad1009c31cc9fb6a035108509e6547547a7a738374f10bd49a09eb3ee3b"}, - {file = "more_itertools-10.6.0-py3-none-any.whl", hash = "sha256:6eb054cb4b6db1473f6e15fcc676a08e4732548acd47c708f0e179c2c7c01e89"}, + {file = "more_itertools-10.7.0-py3-none-any.whl", hash = "sha256:d43980384673cb07d2f7d2d918c616b30c659c089ee23953f601d6609c67510e"}, + {file = "more_itertools-10.7.0.tar.gz", hash = "sha256:9fddd5403be01a94b204faadcff459ec3568cf110265d3c54323e1e866ad29d3"}, ] [[package]] name = "nh3" -version = "0.2.21" +version = "0.3.0" description = "Python binding to Ammonia HTML sanitizer Rust crate" optional = false python-versions = ">=3.8" groups = ["dev"] files = [ - {file = "nh3-0.2.21-cp313-cp313t-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:fcff321bd60c6c5c9cb4ddf2554e22772bb41ebd93ad88171bbbb6f271255286"}, - {file = "nh3-0.2.21-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:31eedcd7d08b0eae28ba47f43fd33a653b4cdb271d64f1aeda47001618348fde"}, - {file = "nh3-0.2.21-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:d426d7be1a2f3d896950fe263332ed1662f6c78525b4520c8e9861f8d7f0d243"}, - {file = "nh3-0.2.21-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:9d67709bc0d7d1f5797b21db26e7a8b3d15d21c9c5f58ccfe48b5328483b685b"}, - {file = "nh3-0.2.21-cp313-cp313t-musllinux_1_2_armv7l.whl", hash = "sha256:55823c5ea1f6b267a4fad5de39bc0524d49a47783e1fe094bcf9c537a37df251"}, - {file = "nh3-0.2.21-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:818f2b6df3763e058efa9e69677b5a92f9bc0acff3295af5ed013da544250d5b"}, - {file = "nh3-0.2.21-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:b3b5c58161e08549904ac4abd450dacd94ff648916f7c376ae4b2c0652b98ff9"}, - {file = "nh3-0.2.21-cp313-cp313t-win32.whl", hash = "sha256:637d4a10c834e1b7d9548592c7aad760611415fcd5bd346f77fd8a064309ae6d"}, - {file = "nh3-0.2.21-cp313-cp313t-win_amd64.whl", hash = "sha256:713d16686596e556b65e7f8c58328c2df63f1a7abe1277d87625dcbbc012ef82"}, - {file = "nh3-0.2.21-cp38-abi3-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:a772dec5b7b7325780922dd904709f0f5f3a79fbf756de5291c01370f6df0967"}, - {file = "nh3-0.2.21-cp38-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d002b648592bf3033adfd875a48f09b8ecc000abd7f6a8769ed86b6ccc70c759"}, - {file = "nh3-0.2.21-cp38-abi3-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:2a5174551f95f2836f2ad6a8074560f261cf9740a48437d6151fd2d4d7d617ab"}, - {file = "nh3-0.2.21-cp38-abi3-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:b8d55ea1fc7ae3633d758a92aafa3505cd3cc5a6e40470c9164d54dff6f96d42"}, - {file = "nh3-0.2.21-cp38-abi3-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:6ae319f17cd8960d0612f0f0ddff5a90700fa71926ca800e9028e7851ce44a6f"}, - {file = "nh3-0.2.21-cp38-abi3-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:63ca02ac6f27fc80f9894409eb61de2cb20ef0a23740c7e29f9ec827139fa578"}, - {file = "nh3-0.2.21-cp38-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a5f77e62aed5c4acad635239ac1290404c7e940c81abe561fd2af011ff59f585"}, - {file = "nh3-0.2.21-cp38-abi3-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:087ffadfdcd497658c3adc797258ce0f06be8a537786a7217649fc1c0c60c293"}, - {file = "nh3-0.2.21-cp38-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:ac7006c3abd097790e611fe4646ecb19a8d7f2184b882f6093293b8d9b887431"}, - {file = "nh3-0.2.21-cp38-abi3-musllinux_1_2_armv7l.whl", hash = "sha256:6141caabe00bbddc869665b35fc56a478eb774a8c1dfd6fba9fe1dfdf29e6efa"}, - {file = "nh3-0.2.21-cp38-abi3-musllinux_1_2_i686.whl", hash = "sha256:20979783526641c81d2f5bfa6ca5ccca3d1e4472474b162c6256745fbfe31cd1"}, - {file = "nh3-0.2.21-cp38-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:a7ea28cd49293749d67e4fcf326c554c83ec912cd09cd94aa7ec3ab1921c8283"}, - {file = "nh3-0.2.21-cp38-abi3-win32.whl", hash = "sha256:6c9c30b8b0d291a7c5ab0967ab200598ba33208f754f2f4920e9343bdd88f79a"}, - {file = "nh3-0.2.21-cp38-abi3-win_amd64.whl", hash = "sha256:bb0014948f04d7976aabae43fcd4cb7f551f9f8ce785a4c9ef66e6c2590f8629"}, - {file = "nh3-0.2.21.tar.gz", hash = "sha256:4990e7ee6a55490dbf00d61a6f476c9a3258e31e711e13713b2ea7d6616f670e"}, + {file = "nh3-0.3.0-cp313-cp313t-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:a537ece1bf513e5a88d8cff8a872e12fe8d0f42ef71dd15a5e7520fecd191bbb"}, + {file = "nh3-0.3.0-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7c915060a2c8131bef6a29f78debc29ba40859b6dbe2362ef9e5fd44f11487c2"}, + {file = "nh3-0.3.0-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:ba0caa8aa184196daa6e574d997a33867d6d10234018012d35f86d46024a2a95"}, + {file = "nh3-0.3.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:80fe20171c6da69c7978ecba33b638e951b85fb92059259edd285ff108b82a6d"}, + {file = "nh3-0.3.0-cp313-cp313t-musllinux_1_2_armv7l.whl", hash = "sha256:e90883f9f85288f423c77b3f5a6f4486375636f25f793165112679a7b6363b35"}, + {file = "nh3-0.3.0-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:0649464ac8eee018644aacbc103874ccbfac80e3035643c3acaab4287e36e7f5"}, + {file = "nh3-0.3.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:1adeb1062a1c2974bc75b8d1ecb014c5fd4daf2df646bbe2831f7c23659793f9"}, + {file = "nh3-0.3.0-cp313-cp313t-win32.whl", hash = "sha256:7275fdffaab10cc5801bf026e3c089d8de40a997afc9e41b981f7ac48c5aa7d5"}, + {file = "nh3-0.3.0-cp313-cp313t-win_amd64.whl", hash = "sha256:423201bbdf3164a9e09aa01e540adbb94c9962cc177d5b1cbb385f5e1e79216e"}, + {file = "nh3-0.3.0-cp313-cp313t-win_arm64.whl", hash = "sha256:16f8670201f7e8e0e05ed1a590eb84bfa51b01a69dd5caf1d3ea57733de6a52f"}, + {file = "nh3-0.3.0-cp38-abi3-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:ec6cfdd2e0399cb79ba4dcffb2332b94d9696c52272ff9d48a630c5dca5e325a"}, + {file = "nh3-0.3.0-cp38-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ce5e7185599f89b0e391e2f29cc12dc2e206167380cea49b33beda4891be2fe1"}, + {file = "nh3-0.3.0-cp38-abi3-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:389d93d59b8214d51c400fb5b07866c2a4f79e4e14b071ad66c92184fec3a392"}, + {file = "nh3-0.3.0-cp38-abi3-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:e9e6a7e4d38f7e8dda9edd1433af5170c597336c1a74b4693c5cb75ab2b30f2a"}, + {file = "nh3-0.3.0-cp38-abi3-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:7852f038a054e0096dac12b8141191e02e93e0b4608c4b993ec7d4ffafea4e49"}, + {file = "nh3-0.3.0-cp38-abi3-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:af5aa8127f62bbf03d68f67a956627b1bd0469703a35b3dad28d0c1195e6c7fb"}, + {file = "nh3-0.3.0-cp38-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f416c35efee3e6a6c9ab7716d9e57aa0a49981be915963a82697952cba1353e1"}, + {file = "nh3-0.3.0-cp38-abi3-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:37d3003d98dedca6cd762bf88f2e70b67f05100f6b949ffe540e189cc06887f9"}, + {file = "nh3-0.3.0-cp38-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:634e34e6162e0408e14fb61d5e69dbaea32f59e847cfcfa41b66100a6b796f62"}, + {file = "nh3-0.3.0-cp38-abi3-musllinux_1_2_armv7l.whl", hash = "sha256:b0612ccf5de8a480cf08f047b08f9d3fecc12e63d2ee91769cb19d7290614c23"}, + {file = "nh3-0.3.0-cp38-abi3-musllinux_1_2_i686.whl", hash = "sha256:c7a32a7f0d89f7d30cb8f4a84bdbd56d1eb88b78a2434534f62c71dac538c450"}, + {file = "nh3-0.3.0-cp38-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:3f1b4f8a264a0c86ea01da0d0c390fe295ea0bcacc52c2103aca286f6884f518"}, + {file = "nh3-0.3.0-cp38-abi3-win32.whl", hash = "sha256:6d68fa277b4a3cf04e5c4b84dd0c6149ff7d56c12b3e3fab304c525b850f613d"}, + {file = "nh3-0.3.0-cp38-abi3-win_amd64.whl", hash = "sha256:bae63772408fd63ad836ec569a7c8f444dd32863d0c67f6e0b25ebbd606afa95"}, + {file = "nh3-0.3.0-cp38-abi3-win_arm64.whl", hash = "sha256:d97d3efd61404af7e5721a0e74d81cdbfc6e5f97e11e731bb6d090e30a7b62b2"}, + {file = "nh3-0.3.0.tar.gz", hash = "sha256:d8ba24cb31525492ea71b6aac11a4adac91d828aadeff7c4586541bf5dc34d2f"}, ] [[package]] name = "packaging" -version = "24.2" +version = "25.0" description = "Core utilities for Python packages" optional = false python-versions = ">=3.8" groups = ["dev"] files = [ - {file = "packaging-24.2-py3-none-any.whl", hash = "sha256:09abb1bccd265c01f4a3aa3f7a7db064b36514d2cba19a2f694fe6150451a759"}, - {file = "packaging-24.2.tar.gz", hash = "sha256:c228a6dc5e932d346bc5739379109d49e8853dd8223571c7c5b55260edc0b97f"}, + {file = "packaging-25.0-py3-none-any.whl", hash = "sha256:29572ef2b1f17581046b3a2227d5c611fb25ec70ca1ba8554b24b0e69331a484"}, + {file = "packaging-25.0.tar.gz", hash = "sha256:d443872c98d677bf60f6a1f2f8c1cb748e8fe762d2bf9d3148b5599295b0fc4f"}, ] [[package]] name = "pluggy" -version = "1.5.0" +version = "1.6.0" description = "plugin and hook calling mechanisms for python" optional = false -python-versions = ">=3.8" +python-versions = ">=3.9" groups = ["dev"] files = [ - {file = "pluggy-1.5.0-py3-none-any.whl", hash = "sha256:44e1ad92c8ca002de6377e165f3e0f1be63266ab4d554740532335b9d75ea669"}, - {file = "pluggy-1.5.0.tar.gz", hash = "sha256:2cffa88e94fdc978c4c574f15f9e59b7f4201d439195c3715ca9e2486f1d0cf1"}, + {file = "pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746"}, + {file = "pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3"}, ] [package.extras] dev = ["pre-commit", "tox"] -testing = ["pytest", "pytest-benchmark"] +testing = ["coverage", "pytest", "pytest-benchmark"] [[package]] name = "pycparser" @@ -855,14 +885,14 @@ markers = {dev = "platform_machine != \"ppc64le\" and platform_machine != \"s390 [[package]] name = "pygments" -version = "2.19.1" +version = "2.19.2" description = "Pygments is a syntax highlighting package written in Python." optional = false python-versions = ">=3.8" groups = ["dev"] files = [ - {file = "pygments-2.19.1-py3-none-any.whl", hash = "sha256:9ea1544ad55cecf4b8242fab6dd35a93bbce657034b0611ee383099054ab6d8c"}, - {file = "pygments-2.19.1.tar.gz", hash = "sha256:61c16d2a8576dc0649d9f39e089b5f02bcd27fba10d8fb4dcc28173f7a45151f"}, + {file = "pygments-2.19.2-py3-none-any.whl", hash = "sha256:86540386c03d588bb81d44bc3928634ff26449851e99741617ecb9037ee5ec0b"}, + {file = "pygments-2.19.2.tar.gz", hash = "sha256:636cb2477cec7f8952536970bc533bc43743542f70392ae026374600add5b887"}, ] [package.extras] @@ -870,26 +900,27 @@ windows-terminal = ["colorama (>=0.4.6)"] [[package]] name = "pytest" -version = "8.3.5" +version = "8.4.1" description = "pytest: simple powerful testing with Python" optional = false -python-versions = ">=3.8" +python-versions = ">=3.9" groups = ["dev"] files = [ - {file = "pytest-8.3.5-py3-none-any.whl", hash = "sha256:c69214aa47deac29fad6c2a4f590b9c4a9fdb16a403176fe154b79c0b4d4d820"}, - {file = "pytest-8.3.5.tar.gz", hash = "sha256:f4efe70cc14e511565ac476b57c279e12a855b11f48f212af1080ef2263d3845"}, + {file = "pytest-8.4.1-py3-none-any.whl", hash = "sha256:539c70ba6fcead8e78eebbf1115e8b589e7565830d7d006a8723f19ac8a0afb7"}, + {file = "pytest-8.4.1.tar.gz", hash = "sha256:7c67fd69174877359ed9371ec3af8a3d2b04741818c51e5e99cc1742251fa93c"}, ] [package.dependencies] -colorama = {version = "*", markers = "sys_platform == \"win32\""} -exceptiongroup = {version = ">=1.0.0rc8", markers = "python_version < \"3.11\""} -iniconfig = "*" -packaging = "*" +colorama = {version = ">=0.4", markers = "sys_platform == \"win32\""} +exceptiongroup = {version = ">=1", markers = "python_version < \"3.11\""} +iniconfig = ">=1" +packaging = ">=20" pluggy = ">=1.5,<2" +pygments = ">=2.7.2" tomli = {version = ">=1", markers = "python_version < \"3.11\""} [package.extras] -dev = ["argcomplete", "attrs (>=19.2)", "hypothesis (>=3.56)", "mock", "pygments (>=2.7.2)", "requests", "setuptools", "xmlschema"] +dev = ["argcomplete", "attrs (>=19.2)", "hypothesis (>=3.56)", "mock", "requests", "setuptools", "xmlschema"] [[package]] name = "pytest-asyncio" @@ -950,14 +981,14 @@ testing = ["pytest-asyncio (==0.24.*)", "pytest-cov (==6.*)"] [[package]] name = "pytest-mock" -version = "3.14.0" +version = "3.14.1" description = "Thin-wrapper around the mock package for easier use with pytest" optional = false python-versions = ">=3.8" groups = ["dev"] files = [ - {file = "pytest-mock-3.14.0.tar.gz", hash = "sha256:2719255a1efeceadbc056d6bf3df3d1c5015530fb40cf347c0f9afac88410bd0"}, - {file = "pytest_mock-3.14.0-py3-none-any.whl", hash = "sha256:0b72c38033392a5f4621342fe11e9219ac11ec9d375f8e2a0c164539e0d70f6f"}, + {file = "pytest_mock-3.14.1-py3-none-any.whl", hash = "sha256:178aefcd11307d874b4cd3100344e7e2d888d9791a6a1d9bfe90fbc1b74fd1d0"}, + {file = "pytest_mock-3.14.1.tar.gz", hash = "sha256:159e9edac4c451ce77a5cdb9fc5d1100708d2dd4ba3c3df572f14097351af80e"}, ] [package.dependencies] @@ -1001,19 +1032,19 @@ md = ["cmarkgfm (>=0.8.0)"] [[package]] name = "requests" -version = "2.32.3" +version = "2.32.4" description = "Python HTTP for Humans." optional = false python-versions = ">=3.8" groups = ["main", "dev"] files = [ - {file = "requests-2.32.3-py3-none-any.whl", hash = "sha256:70761cfe03c773ceb22aa2f671b4757976145175cdfca038c02654d061d6dcc6"}, - {file = "requests-2.32.3.tar.gz", hash = "sha256:55365417734eb18255590a9ff9eb97e9e1da868d4ccd6402399eaf68af20a760"}, + {file = "requests-2.32.4-py3-none-any.whl", hash = "sha256:27babd3cda2a6d50b30443204ee89830707d396671944c998b5975b031ac2b2c"}, + {file = "requests-2.32.4.tar.gz", hash = "sha256:27d0316682c8a29834d3264820024b62a36942083d52caf2f14c0591336d3422"}, ] [package.dependencies] certifi = ">=2017.4.17" -charset-normalizer = ">=2,<4" +charset_normalizer = ">=2,<4" idna = ">=2.5,<4" urllib3 = ">=1.21.1,<3" @@ -1053,20 +1084,19 @@ idna2008 = ["idna"] [[package]] name = "rich" -version = "14.0.0" +version = "14.1.0" description = "Render rich text, tables, progress bars, syntax highlighting, markdown and more to the terminal" optional = false python-versions = ">=3.8.0" groups = ["dev"] files = [ - {file = "rich-14.0.0-py3-none-any.whl", hash = "sha256:1c9491e1951aac09caffd42f448ee3d04e58923ffe14993f6e83068dc395d7e0"}, - {file = "rich-14.0.0.tar.gz", hash = "sha256:82f1bc23a6a21ebca4ae0c45af9bdbc492ed20231dcb63f297d6d1021a9d5725"}, + {file = "rich-14.1.0-py3-none-any.whl", hash = "sha256:536f5f1785986d6dbdea3c75205c473f970777b4a0d6c6dd1b696aa05a3fa04f"}, + {file = "rich-14.1.0.tar.gz", hash = "sha256:e497a48b844b0320d45007cdebfeaeed8db2a4f4bcf49f15e455cfc4af11eaa8"}, ] [package.dependencies] markdown-it-py = ">=2.2.0" pygments = ">=2.13.0,<3.0.0" -typing-extensions = {version = ">=4.0.0,<5.0", markers = "python_version < \"3.11\""} [package.extras] jupyter = ["ipywidgets (>=7.5.1,<9)"] @@ -1199,27 +1229,27 @@ keyring = ["keyring (>=15.1)"] [[package]] name = "typing-extensions" -version = "4.13.1" -description = "Backported and Experimental Type Hints for Python 3.8+" +version = "4.14.1" +description = "Backported and Experimental Type Hints for Python 3.9+" optional = false -python-versions = ">=3.8" +python-versions = ">=3.9" groups = ["main", "dev"] markers = "python_version < \"3.13\"" files = [ - {file = "typing_extensions-4.13.1-py3-none-any.whl", hash = "sha256:4b6cf02909eb5495cfbc3f6e8fd49217e6cc7944e145cdda8caa3734777f9e69"}, - {file = "typing_extensions-4.13.1.tar.gz", hash = "sha256:98795af00fb9640edec5b8e31fc647597b4691f099ad75f469a2616be1a76dff"}, + {file = "typing_extensions-4.14.1-py3-none-any.whl", hash = "sha256:d1e1e3b58374dc93031d6eda2420a48ea44a36c2b4766a4fdeb3710755731d76"}, + {file = "typing_extensions-4.14.1.tar.gz", hash = "sha256:38b39f4aeeab64884ce9f74c94263ef78f3c22467c8724005483154c26648d36"}, ] [[package]] name = "urllib3" -version = "2.3.0" +version = "2.5.0" description = "HTTP library with thread-safe connection pooling, file post, and more." optional = false python-versions = ">=3.9" groups = ["main", "dev"] files = [ - {file = "urllib3-2.3.0-py3-none-any.whl", hash = "sha256:1cee9ad369867bfdbbb48b7dd50374c0967a0bb7710050facf0dd6911440e3df"}, - {file = "urllib3-2.3.0.tar.gz", hash = "sha256:f8c5449b3cf0861679ce7e0503c7b44b5ec981bec0d1d3795a07f1ba96f0204d"}, + {file = "urllib3-2.5.0-py3-none-any.whl", hash = "sha256:e6b01673c0fa6a13e374b50871808eb3bf7046c4b125b216f6bf1cc604cff0dc"}, + {file = "urllib3-2.5.0.tar.gz", hash = "sha256:3fc47733c7e419d4bc3f6b3dc2b4f890bb743906a30d56ba4a5bfa4bbff92760"}, ] [package.extras] @@ -1230,15 +1260,15 @@ zstd = ["zstandard (>=0.18.0)"] [[package]] name = "zipp" -version = "3.21.0" +version = "3.23.0" description = "Backport of pathlib-compatible object wrapper for zip files" optional = false python-versions = ">=3.9" groups = ["dev"] markers = "platform_machine != \"ppc64le\" and platform_machine != \"s390x\" and python_version < \"3.12\" or python_version == \"3.9\"" files = [ - {file = "zipp-3.21.0-py3-none-any.whl", hash = "sha256:ac1bbe05fd2991f160ebce24ffbac5f6d11d83dc90891255885223d42b3cd931"}, - {file = "zipp-3.21.0.tar.gz", hash = "sha256:2c9958f6430a2040341a52eb608ed6dd93ef4392e02ffe219417c1b28b5dd1f4"}, + {file = "zipp-3.23.0-py3-none-any.whl", hash = "sha256:071652d6115ed432f5ce1d34c336c0adfd6a884660d1e9712a256d3d3bd4b14e"}, + {file = "zipp-3.23.0.tar.gz", hash = "sha256:a07157588a12518c9d4034df3fbbee09c814741a33ff63c05fa29d26a2404166"}, ] [package.extras] @@ -1246,7 +1276,7 @@ check = ["pytest-checkdocs (>=2.4)", "pytest-ruff (>=0.2.1) ; sys_platform != \" cover = ["pytest-cov"] doc = ["furo", "jaraco.packaging (>=9.3)", "jaraco.tidelift (>=1.4)", "rst.linker (>=1.9)", "sphinx (>=3.5)", "sphinx-lint"] enabler = ["pytest-enabler (>=2.2)"] -test = ["big-O", "importlib-resources ; python_version < \"3.9\"", "jaraco.functools", "jaraco.itertools", "jaraco.test", "more-itertools", "pytest (>=6,!=8.1.*)", "pytest-ignore-flaky"] +test = ["big-O", "jaraco.functools", "jaraco.itertools", "jaraco.test", "more_itertools", "pytest (>=6,!=8.1.*)", "pytest-ignore-flaky"] type = ["pytest-mypy"] [metadata] diff --git a/packages/auth0_api_python/src/auth0_api_python/api_client.py b/packages/auth0_api_python/src/auth0_api_python/api_client.py index 8868445..c030a81 100644 --- a/packages/auth0_api_python/src/auth0_api_python/api_client.py +++ b/packages/auth0_api_python/src/auth0_api_python/api_client.py @@ -101,12 +101,13 @@ async def verify_request( scheme = scheme.strip().lower() if self.is_dpop_required() and scheme != "dpop": - raise self._prepare_error( - InvalidAuthSchemeError( - f"Invalid scheme. Expected DPoP{', but got ' + scheme + '.' if scheme and scheme != 'dpop' else ' scheme.'}" - ), - auth_scheme=scheme - ) + error_detail = f", but got '{scheme}'." if scheme == 'bearer' else " scheme." + raise self._prepare_error( + InvalidAuthSchemeError( + f"Invalid scheme. Expected 'DPoP'{error_detail}" + ), + auth_scheme=scheme + ) if not token.strip(): raise self._prepare_error(MissingAuthorizationError()) diff --git a/packages/auth0_api_python/tests/test_api_client.py b/packages/auth0_api_python/tests/test_api_client.py index 4de1de6..a03fe9f 100644 --- a/packages/auth0_api_python/tests/test_api_client.py +++ b/packages/auth0_api_python/tests/test_api_client.py @@ -1359,7 +1359,7 @@ async def test_verify_request_fail_dpop_required_mode(): http_url="https://api.example.com/resource" ) - assert "expected dpop, but got bearer" in str(err.value).lower() + assert "expected 'dpop', but got 'bearer'" in str(err.value).lower() @pytest.mark.asyncio async def test_verify_request_fail_dpop_enabled_bearer_with_cnf_conflict(httpx_mock: HTTPXMock): From 87916fd889a6dc165a424e7cd70f48e872227dcb Mon Sep 17 00:00:00 2001 From: Snehil Kishore Date: Thu, 7 Aug 2025 12:08:39 +0530 Subject: [PATCH 18/23] Update packages/auth0_api_python/EXAMPLES.md Co-authored-by: Rita Zerrizuela --- packages/auth0_api_python/EXAMPLES.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/auth0_api_python/EXAMPLES.md b/packages/auth0_api_python/EXAMPLES.md index b361dca..db6e8f6 100644 --- a/packages/auth0_api_python/EXAMPLES.md +++ b/packages/auth0_api_python/EXAMPLES.md @@ -67,7 +67,7 @@ result = asyncio.run(validate_request(headers)) ## DPoP Authentication -**DPoP (Demonstrating Proof-of-Possession)** is a security extension that binds access tokens to cryptographic keys, preventing token theft and replay attacks. +[DPoP](https://www.rfc-editor.org/rfc/rfc9449.html) (Demonstrating Proof of Posession) is an application-level mechanism for sender-constraining OAuth 2.0 access and refresh tokens by proving that the client application is in possession of a certain private key. This guide covers the DPoP implementation in `auth0-api-python` with complete examples for both operational modes. From 8cbbe9d6fcfc126d31770ccc8e705c40b86174a9 Mon Sep 17 00:00:00 2001 From: Snehil Kishore Date: Tue, 12 Aug 2025 15:03:14 +0530 Subject: [PATCH 19/23] fix: preserve trailing slashes in DPoP proof URL normalization and add error handling --- .../src/auth0_api_python/api_client.py | 7 ++- .../src/auth0_api_python/utils.py | 26 +++++++---- .../auth0_api_python/tests/test_api_client.py | 43 +++++++++++++++++++ 3 files changed, 67 insertions(+), 9 deletions(-) diff --git a/packages/auth0_api_python/src/auth0_api_python/api_client.py b/packages/auth0_api_python/src/auth0_api_python/api_client.py index c030a81..27d68e7 100644 --- a/packages/auth0_api_python/src/auth0_api_python/api_client.py +++ b/packages/auth0_api_python/src/auth0_api_python/api_client.py @@ -376,7 +376,12 @@ async def verify_dpop_proof( if claims["htm"].lower() != http_method.lower(): raise InvalidDpopProofError("DPoP Proof htm mismatch") - if normalize_url_for_htu(claims["htu"]) != normalize_url_for_htu(http_url): + try: + normalized_htu = normalize_url_for_htu(claims["htu"]) + normalized_http_url = normalize_url_for_htu(http_url) + if normalized_htu != normalized_http_url: + raise InvalidDpopProofError("DPoP Proof htu mismatch") + except ValueError: raise InvalidDpopProofError("DPoP Proof htu mismatch") if claims["ath"] != sha256_base64url(access_token): diff --git a/packages/auth0_api_python/src/auth0_api_python/utils.py b/packages/auth0_api_python/src/auth0_api_python/utils.py index 7357bf5..4ab8051 100644 --- a/packages/auth0_api_python/src/auth0_api_python/utils.py +++ b/packages/auth0_api_python/src/auth0_api_python/utils.py @@ -95,19 +95,29 @@ def remove_bytes_prefix(s: str) -> str: def normalize_url_for_htu(raw_url: str) -> str: """ Normalize URL for DPoP htu comparison . + + Args: + raw_url: The raw URL string to normalize + Returns: + The normalized URL string + Raises: + ValueError: If the URL is invalid or cannot be parsed """ - url_obj = URL(raw_url) + try: + url_obj = URL(raw_url) - normalized_url = url_obj.origin + url_obj.pathname + normalized_url = url_obj.origin + url_obj.pathname - normalized_url = re.sub( - r'%([0-9a-fA-F]{2})', - lambda m: f'%{m.group(1).upper()}', - normalized_url - ) + normalized_url = re.sub( + r'%([0-9a-fA-F]{2})', + lambda m: f'%{m.group(1).upper()}', + normalized_url + ) - return normalized_url + return normalized_url + except Exception as e: + raise ValueError(f"Invalid URL format: {raw_url}") from e def sha256_base64url(input_str: Union[str, bytes]) -> str: """ diff --git a/packages/auth0_api_python/tests/test_api_client.py b/packages/auth0_api_python/tests/test_api_client.py index a03fe9f..5ee0cd0 100644 --- a/packages/auth0_api_python/tests/test_api_client.py +++ b/packages/auth0_api_python/tests/test_api_client.py @@ -1176,6 +1176,49 @@ async def test_verify_dpop_proof_htu_fragment_handling(): ) assert result is not None + +@pytest.mark.asyncio +async def test_verify_dpop_proof_htu_trailing_slash_preserved(): + """ + Test that trailing slashes are preserved when query params and fragments are removed. + """ + access_token = "test_token" + + # Generate proof with trailing slash and query parameters + dpop_proof = await generate_dpop_proof( + access_token=access_token, + http_method="GET", + http_url="https://api.example.com/resource/?abc=def" + ) + + api_client = ApiClient(ApiClientOptions(domain="auth0.local", audience="my-audience")) + + # This should succeed because normalization preserves + result = await api_client.verify_dpop_proof( + access_token=access_token, + proof=dpop_proof, + http_method="GET", + http_url="https://api.example.com/resource/" # With trailing slash, no query params + ) + + assert result["htu"] == "https://api.example.com/resource/" + + # Additional test with a different combination + dpop_proof2 = await generate_dpop_proof( + access_token=access_token, + http_method="GET", + http_url="https://api.example.com/resource/?abc=def#fragment" + ) + + result2 = await api_client.verify_dpop_proof( + access_token=access_token, + proof=dpop_proof2, + http_method="GET", + http_url="https://api.example.com/resource/" + ) + + assert result2["htu"] == "https://api.example.com/resource/" + @pytest.mark.asyncio async def test_verify_dpop_proof_fail_ath_mismatch(): """ From 5247857ba44b047bd3d293fddc74e99c1931ff82 Mon Sep 17 00:00:00 2001 From: Snehil Kishore Date: Tue, 12 Aug 2025 15:04:21 +0530 Subject: [PATCH 20/23] fix: remove trailing whitespace in test_api_client.py URL parameter --- packages/auth0_api_python/tests/test_api_client.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/auth0_api_python/tests/test_api_client.py b/packages/auth0_api_python/tests/test_api_client.py index 5ee0cd0..b7a4321 100644 --- a/packages/auth0_api_python/tests/test_api_client.py +++ b/packages/auth0_api_python/tests/test_api_client.py @@ -1207,7 +1207,7 @@ async def test_verify_dpop_proof_htu_trailing_slash_preserved(): dpop_proof2 = await generate_dpop_proof( access_token=access_token, http_method="GET", - http_url="https://api.example.com/resource/?abc=def#fragment" + http_url="https://api.example.com/resource/?abc=def#fragment" ) result2 = await api_client.verify_dpop_proof( From 94bae8be272918b3022bc9714c660e90a29a504a Mon Sep 17 00:00:00 2001 From: Snehil Kishore Date: Wed, 20 Aug 2025 13:59:30 +0530 Subject: [PATCH 21/23] fix: update error handling for authorization and DPoP validation to return 400 status code with appropriate error messages --- .../src/auth0_api_python/api_client.py | 79 ++++++++++--------- .../src/auth0_api_python/errors.py | 15 ++-- .../auth0_api_python/tests/test_api_client.py | 24 +++--- 3 files changed, 63 insertions(+), 55 deletions(-) diff --git a/packages/auth0_api_python/src/auth0_api_python/api_client.py b/packages/auth0_api_python/src/auth0_api_python/api_client.py index 27d68e7..69aeb6a 100644 --- a/packages/auth0_api_python/src/auth0_api_python/api_client.py +++ b/packages/auth0_api_python/src/auth0_api_python/api_client.py @@ -81,7 +81,7 @@ async def verify_request( if not authorization_header: if self.is_dpop_required(): raise self._prepare_error( - InvalidAuthSchemeError("Expecting Authorization header with DPoP scheme.") + InvalidAuthSchemeError("") ) else : raise self._prepare_error(MissingAuthorizationError()) @@ -93,7 +93,7 @@ async def verify_request( raise self._prepare_error(MissingAuthorizationError()) elif len(parts) > 2: raise self._prepare_error( - InvalidAuthSchemeError("Invalid Authorization HTTP Header Format for authorization") + InvalidAuthSchemeError("Invalid Authorization HTTP Header Format") ) scheme, token = parts @@ -101,11 +101,8 @@ async def verify_request( scheme = scheme.strip().lower() if self.is_dpop_required() and scheme != "dpop": - error_detail = f", but got '{scheme}'." if scheme == 'bearer' else " scheme." raise self._prepare_error( - InvalidAuthSchemeError( - f"Invalid scheme. Expected 'DPoP'{error_detail}" - ), + InvalidAuthSchemeError(""), auth_scheme=scheme ) if not token.strip(): @@ -119,12 +116,12 @@ async def verify_request( if not dpop_proof: if self.is_dpop_required(): raise self._prepare_error( - InvalidAuthSchemeError("Expecting Authorization header with DPoP scheme."), + InvalidAuthSchemeError(""), auth_scheme=scheme ) else: raise self._prepare_error( - InvalidDpopProofError("Operation indicated DPoP use but the request has no DPoP HTTP Header"), + InvalidAuthSchemeError(""), auth_scheme=scheme ) @@ -140,8 +137,15 @@ async def verify_request( raise self._prepare_error(InvalidDpopProofError("Failed to verify DPoP proof"), auth_scheme=scheme) if not http_method or not http_url: + missing_params = [] + if not http_method: + missing_params.append("http_method") + if not http_url: + missing_params.append("http_url") + raise self._prepare_error( - InvalidDpopProofError("Operation indicated DPoP use but the request has no http_method or http_url"), auth_scheme=scheme + MissingRequiredArgumentError(f"DPoP authentication requires {' and '.join(missing_params)}"), + auth_scheme=scheme ) try: @@ -153,13 +157,13 @@ async def verify_request( if not cnf_claim: raise self._prepare_error( - InvalidDpopProofError("Operation indicated DPoP use but the JWT Access Token has no jkt confirmation claim"), + VerifyAccessTokenError("JWT Access Token has no jkt confirmation claim"), auth_scheme=scheme ) if not isinstance(cnf_claim, dict): raise self._prepare_error( - InvalidDpopProofError("Operation indicated DPoP use but the JWT Access Token has invalid confirmation claim format"), + VerifyAccessTokenError("JWT Access Token has invalid confirmation claim format"), auth_scheme=scheme ) try: @@ -192,27 +196,24 @@ async def verify_request( return access_token_claims if scheme == "bearer": - if dpop_proof: - if self.options.dpop_enabled: - raise self._prepare_error( - InvalidAuthSchemeError( - "Operation indicated DPoP use but the request's Authorization HTTP Header scheme is not DPoP" - ), - auth_scheme=scheme - ) - try: claims = await self.verify_access_token(token) if claims.get("cnf") and isinstance(claims["cnf"], dict) and claims["cnf"].get("jkt"): + if self.options.dpop_enabled: + raise self._prepare_error( + VerifyAccessTokenError( + "DPoP-bound token requires the DPoP authentication scheme, not Bearer" + ), + auth_scheme=scheme + ) + if dpop_proof: if self.options.dpop_enabled: raise self._prepare_error( InvalidAuthSchemeError( - "Operation indicated DPoP use but the request's Authorization HTTP Header scheme is not DPoP" + "DPoP proof requires DPoP authentication scheme, not Bearer" ), auth_scheme=scheme ) - - return claims except VerifyAccessTokenError as e: raise self._prepare_error(e, auth_scheme=scheme) @@ -487,42 +488,44 @@ def _build_www_authenticate( error_description: Error description if any auth_scheme: The authentication scheme that was used ("bearer" or "dpop") """ + # Check if we should omit error parameters (invalid_request with empty description) + should_omit_error = (error_code == "invalid_request" and error_description == "") + # If DPoP is disabled, only return Bearer challenges if not self.options.dpop_enabled: - if error_code and error_code != "unauthorized": + if error_code and error_code != "unauthorized" and not should_omit_error: bearer_parts = [] bearer_parts.append(f'error="{error_code}"') if error_description: bearer_parts.append(f'error_description="{error_description}"') return [("WWW-Authenticate", "Bearer " + ", ".join(bearer_parts))] - return [("WWW-Authenticate", "Bearer")] + return [("WWW-Authenticate", 'Bearer realm="api"')] algs = " ".join(self._dpop_algorithms) dpop_required = self.is_dpop_required() - # No error details - if error_code == "unauthorized" or not error_code: + # No error details or should omit error cases + if error_code == "unauthorized" or not error_code or should_omit_error: if dpop_required: return [("WWW-Authenticate", f'DPoP algs="{algs}"')] - return [("WWW-Authenticate", f'Bearer, DPoP algs="{algs}"')] + return [("WWW-Authenticate", f'Bearer realm="api", DPoP algs="{algs}"')] if dpop_required: # DPoP-required mode: Single DPoP challenge with error dpop_parts = [] - if error_code: + if error_code and not should_omit_error: dpop_parts.append(f'error="{error_code}"') - if error_description: - dpop_parts.append(f'error_description="{error_description}"') + if error_description: + dpop_parts.append(f'error_description="{error_description}"') dpop_parts.append(f'algs="{algs}"') dpop_header = "DPoP " + ", ".join(dpop_parts) return [("WWW-Authenticate", dpop_header)] # DPoP-allowed mode: For DPoP errors, always include both challenges - if auth_scheme == "dpop" and error_code: - bearer_header = "Bearer" + if auth_scheme == "dpop" and error_code and not should_omit_error: + bearer_header = 'Bearer realm="api"' dpop_parts = [] - if error_code: - dpop_parts.append(f'error="{error_code}"') + dpop_parts.append(f'error="{error_code}"') if error_description: dpop_parts.append(f'error_description="{error_description}"') dpop_parts.append(f'algs="{algs}"') @@ -533,7 +536,7 @@ def _build_www_authenticate( ] # If auth_scheme is "bearer", include error on Bearer challenge - if auth_scheme == "bearer" and error_code: + if auth_scheme == "bearer" and error_code and not should_omit_error: bearer_parts = [] bearer_parts.append(f'error="{error_code}"') if error_description: @@ -542,8 +545,8 @@ def _build_www_authenticate( dpop_header = f'DPoP algs="{algs}"' return [("WWW-Authenticate", f'{bearer_header}, {dpop_header}')] - # Default: no error or unknown context + # Default: no error or should omit error context return [ - ("WWW-Authenticate", "Bearer"), + ("WWW-Authenticate", 'Bearer realm="api"'), ("WWW-Authenticate", f'DPoP algs="{algs}"'), ] diff --git a/packages/auth0_api_python/src/auth0_api_python/errors.py b/packages/auth0_api_python/src/auth0_api_python/errors.py index 9218b73..e696c15 100644 --- a/packages/auth0_api_python/src/auth0_api_python/errors.py +++ b/packages/auth0_api_python/src/auth0_api_python/errors.py @@ -32,8 +32,11 @@ def get_headers(self) -> dict[str, str]: class MissingRequiredArgumentError(BaseAuthError): """Error raised when a required argument is missing.""" - def __init__(self, argument: str): - super().__init__(f"The argument '{argument}' is required but was not provided.") + def __init__(self, argument: str, message: str = None): + if message: + super().__init__(message) + else: + super().__init__(f"The argument '{argument}' is required but was not provided.") self.argument = argument def get_status_code(self) -> int: @@ -87,11 +90,7 @@ def __init__(self): super().__init__("") def get_status_code(self) -> int: - return 401 + return 400 def get_error_code(self) -> str: - return "" - - def get_error_description(self) -> str: - return "" - + return "invalid_request" diff --git a/packages/auth0_api_python/tests/test_api_client.py b/packages/auth0_api_python/tests/test_api_client.py index b7a4321..1278f34 100644 --- a/packages/auth0_api_python/tests/test_api_client.py +++ b/packages/auth0_api_python/tests/test_api_client.py @@ -1402,7 +1402,8 @@ async def test_verify_request_fail_dpop_required_mode(): http_url="https://api.example.com/resource" ) - assert "expected 'dpop', but got 'bearer'" in str(err.value).lower() + assert err.value.get_status_code() == 400 + assert "invalid_request" in str(err.value.get_error_code()).lower() @pytest.mark.asyncio async def test_verify_request_fail_dpop_enabled_bearer_with_cnf_conflict(httpx_mock: HTTPXMock): @@ -1441,14 +1442,14 @@ async def test_verify_request_fail_dpop_enabled_bearer_with_cnf_conflict(httpx_m ) ) - with pytest.raises(InvalidAuthSchemeError) as err: + with pytest.raises(VerifyAccessTokenError) as err: await api_client.verify_request( headers={"authorization": f"Bearer {token}"}, http_method="GET", http_url="https://api.example.com/resource" ) - assert "request's authorization http header scheme is not dpop" in str(err.value).lower() + assert "dpop-bound token requires the dpop authentication scheme, not bearer" in str(err.value).lower() @pytest.mark.asyncio async def test_verify_request_fail_dpop_disabled(): @@ -1477,7 +1478,8 @@ async def test_verify_request_fail_dpop_disabled(): http_url="https://api.example.com/resource" ) - assert err.value.get_status_code() == 401 + assert err.value.get_status_code() == 400 + assert "invalid_request" in str(err.value.get_error_code()).lower() @pytest.mark.asyncio async def test_verify_request_fail_missing_authorization_header(): @@ -1494,7 +1496,8 @@ async def test_verify_request_fail_missing_authorization_header(): http_method="GET", http_url="https://api.example.com/resource" ) - assert err.value.get_status_code() == 401 + assert err.value.get_status_code() == 400 + assert "invalid_request" in str(err.value.get_error_code()).lower() @pytest.mark.asyncio async def test_verify_request_fail_unsupported_scheme(): @@ -1511,7 +1514,8 @@ async def test_verify_request_fail_unsupported_scheme(): http_method="GET", http_url="https://api.example.com/resource" ) - assert err.value.get_status_code() == 401 + assert err.value.get_status_code() == 400 + assert "invalid_request" in str(err.value.get_error_code()).lower() @pytest.mark.asyncio async def test_verify_request_fail_empty_bearer_token(): @@ -1519,7 +1523,8 @@ async def test_verify_request_fail_empty_bearer_token(): api_client = ApiClient(ApiClientOptions(domain="auth0.local", audience="my-audience")) with pytest.raises(MissingAuthorizationError) as err: await api_client.verify_request({"Authorization": "Bearer "}) - assert err.value.get_status_code() == 401 + assert err.value.get_status_code() == 400 + assert "invalid_request" in str(err.value.get_error_code()).lower() @pytest.mark.asyncio async def test_verify_request_with_multiple_spaces_in_authorization(): @@ -1542,14 +1547,15 @@ async def test_verify_request_fail_missing_dpop_header(): ApiClientOptions(domain="auth0.local", audience="my-audience") ) - with pytest.raises(InvalidDpopProofError) as err: + with pytest.raises(InvalidAuthSchemeError) as err: await api_client.verify_request( headers={"authorization": f"DPoP {access_token}"}, # Missing DPoP header http_method="GET", http_url="https://api.example.com/resource" ) - assert "request has no dpop http header" in str(err.value).lower() + assert err.value.get_status_code() == 400 + assert "invalid_request" in str(err.value.get_error_code()).lower() @pytest.mark.asyncio async def test_verify_request_fail_multiple_dpop_proofs(): From 9249fa2be8f751423114c5945b52831ae8463e44 Mon Sep 17 00:00:00 2001 From: Snehil Kishore Date: Wed, 20 Aug 2025 14:10:53 +0530 Subject: [PATCH 22/23] fix: improve error message for unsupported algorithm in DPoP proof validation --- packages/auth0_api_python/src/auth0_api_python/api_client.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/auth0_api_python/src/auth0_api_python/api_client.py b/packages/auth0_api_python/src/auth0_api_python/api_client.py index 69aeb6a..93eebbc 100644 --- a/packages/auth0_api_python/src/auth0_api_python/api_client.py +++ b/packages/auth0_api_python/src/auth0_api_python/api_client.py @@ -328,7 +328,7 @@ async def verify_dpop_proof( alg = header.get("alg") if alg not in self._dpop_algorithms: - raise InvalidDpopProofError(f"Unsupported alg: {alg}") + raise InvalidDpopProofError("Unsupported algorithm in DPoP proof") jwk_dict = header.get("jwk") if not jwk_dict or not isinstance(jwk_dict, dict): From 692e45a36f21fd5a442c548bdf9a94c7e886deb6 Mon Sep 17 00:00:00 2001 From: Snehil Kishore Date: Thu, 21 Aug 2025 23:17:15 +0530 Subject: [PATCH 23/23] fix: improve error handling for invalid authorization scheme and update test assertions --- packages/auth0_api_python/src/auth0_api_python/api_client.py | 2 +- packages/auth0_api_python/tests/test_api_client.py | 3 ++- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/packages/auth0_api_python/src/auth0_api_python/api_client.py b/packages/auth0_api_python/src/auth0_api_python/api_client.py index 93eebbc..0eb7a22 100644 --- a/packages/auth0_api_python/src/auth0_api_python/api_client.py +++ b/packages/auth0_api_python/src/auth0_api_python/api_client.py @@ -93,7 +93,7 @@ async def verify_request( raise self._prepare_error(MissingAuthorizationError()) elif len(parts) > 2: raise self._prepare_error( - InvalidAuthSchemeError("Invalid Authorization HTTP Header Format") + InvalidAuthSchemeError("") ) scheme, token = parts diff --git a/packages/auth0_api_python/tests/test_api_client.py b/packages/auth0_api_python/tests/test_api_client.py index 1278f34..afa3c99 100644 --- a/packages/auth0_api_python/tests/test_api_client.py +++ b/packages/auth0_api_python/tests/test_api_client.py @@ -1534,7 +1534,8 @@ async def test_verify_request_with_multiple_spaces_in_authorization(): ) with pytest.raises(InvalidAuthSchemeError) as err: await api_client.verify_request({"authorization": "Bearer token with extra spaces"}) - assert "authorization" in str(err.value).lower() + assert err.value.get_status_code() == 400 + assert "invalid_request" in str(err.value.get_error_code()).lower() @pytest.mark.asyncio async def test_verify_request_fail_missing_dpop_header():