Compare commits

...

3 Commits

Author SHA1 Message Date
Felipe Cardoso
f8aafb250d fix(backend): suppress license-check output in Makefile for cleaner logs
- Redirect pip-licenses output to `/dev/null` to reduce noise during license checks.
- Retain success and compliance messages for clear feedback.
2026-03-01 14:24:22 +01:00
Felipe Cardoso
4385d20ca6 fix(tests): simplify invalid token test logic in test_auth_security.py
- Removed unnecessary try-except block for JWT encoding failures.
- Adjusted test to directly verify `TokenInvalidError` during decoding.
- Clarified comment on HMAC algorithm compatibility (`HS384` vs. `HS256`).
2026-03-01 14:24:17 +01:00
Felipe Cardoso
1a36907f10 refactor(backend): replace python-jose and passlib with PyJWT and bcrypt for security and simplicity
- Migrated JWT token handling from `python-jose` to `PyJWT`, reducing dependencies and improving error clarity.
- Replaced `passlib` bcrypt integration with direct `bcrypt` usage for password hashing.
- Updated `Makefile`, removing unused CVE ignore based on the replaced dependencies.
- Reflected changes in `ARCHITECTURE.md` and adjusted function headers in `auth.py`.
- Cleaned up `uv.lock` and `pyproject.toml` to remove unused dependencies (`ecdsa`, `rsa`, etc.) and add `PyJWT`.
- Refactored tests and services to align with the updated libraries (`PyJWT` error handling, decoding, and validation).
2026-03-01 14:02:04 +01:00
9 changed files with 90 additions and 151 deletions

View File

@@ -86,14 +86,12 @@ validate: lint format-check type-check
dep-audit:
@echo "🔒 Scanning dependencies for known vulnerabilities..."
@# CVE-2024-23342: ecdsa timing attack via python-jose (transitive). No fix available.
@# We only use HS256 (not ECDSA signing), so this is not exploitable. Track python-jose replacement separately.
@uv run pip-audit --desc --skip-editable --ignore-vuln CVE-2024-23342
@uv run pip-audit --desc --skip-editable
@echo "✅ No known vulnerabilities found!"
license-check:
@echo "📜 Checking dependency license compliance..."
@uv run pip-licenses --fail-on="GPL-3.0-or-later;AGPL-3.0-or-later" --format=plain
@uv run pip-licenses --fail-on="GPL-3.0-or-later;AGPL-3.0-or-later" --format=plain > /dev/null
@echo "✅ All dependency licenses are compliant!"
audit: dep-audit license-check

View File

@@ -1,23 +1,21 @@
import asyncio
import logging
import uuid
from datetime import UTC, datetime, timedelta
from functools import partial
from typing import Any
from jose import JWTError, jwt
from passlib.context import CryptContext
import bcrypt
import jwt
from jwt.exceptions import (
ExpiredSignatureError,
InvalidTokenError,
MissingRequiredClaimError,
)
from pydantic import ValidationError
from app.core.config import settings
from app.schemas.users import TokenData, TokenPayload
# Suppress passlib bcrypt warnings about ident
logging.getLogger("passlib").setLevel(logging.ERROR)
# Password hashing context
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
# Custom exceptions for auth
class AuthError(Exception):
@@ -37,13 +35,16 @@ class TokenMissingClaimError(AuthError):
def verify_password(plain_password: str, hashed_password: str) -> bool:
"""Verify a password against a hash."""
return pwd_context.verify(plain_password, hashed_password)
"""Verify a password against a bcrypt hash."""
return bcrypt.checkpw(
plain_password.encode("utf-8"), hashed_password.encode("utf-8")
)
def get_password_hash(password: str) -> str:
"""Generate a password hash."""
return pwd_context.hash(password)
"""Generate a bcrypt password hash."""
salt = bcrypt.gensalt()
return bcrypt.hashpw(password.encode("utf-8"), salt).decode("utf-8")
async def verify_password_async(plain_password: str, hashed_password: str) -> bool:
@@ -60,9 +61,9 @@ async def verify_password_async(plain_password: str, hashed_password: str) -> bo
Returns:
True if password matches, False otherwise
"""
loop = asyncio.get_event_loop()
loop = asyncio.get_running_loop()
return await loop.run_in_executor(
None, partial(pwd_context.verify, plain_password, hashed_password)
None, partial(verify_password, plain_password, hashed_password)
)
@@ -80,8 +81,8 @@ async def get_password_hash_async(password: str) -> str:
Returns:
Hashed password string
"""
loop = asyncio.get_event_loop()
return await loop.run_in_executor(None, pwd_context.hash, password)
loop = asyncio.get_running_loop()
return await loop.run_in_executor(None, get_password_hash, password)
def create_access_token(
@@ -121,11 +122,7 @@ def create_access_token(
to_encode.update(claims)
# Create the JWT
encoded_jwt = jwt.encode(
to_encode, settings.SECRET_KEY, algorithm=settings.ALGORITHM
)
return encoded_jwt
return jwt.encode(to_encode, settings.SECRET_KEY, algorithm=settings.ALGORITHM)
def create_refresh_token(
@@ -154,11 +151,7 @@ def create_refresh_token(
"type": "refresh",
}
encoded_jwt = jwt.encode(
to_encode, settings.SECRET_KEY, algorithm=settings.ALGORITHM
)
return encoded_jwt
return jwt.encode(to_encode, settings.SECRET_KEY, algorithm=settings.ALGORITHM)
def decode_token(token: str, verify_type: str | None = None) -> TokenPayload:
@@ -198,7 +191,7 @@ def decode_token(token: str, verify_type: str | None = None) -> TokenPayload:
# Reject weak or unexpected algorithms
# NOTE: These are defensive checks that provide defense-in-depth.
# The python-jose library rejects these tokens BEFORE we reach here,
# PyJWT rejects these tokens BEFORE we reach here,
# but we keep these checks in case the library changes or is misconfigured.
# Coverage: Marked as pragma since library catches first (see tests/core/test_auth_security.py)
if token_algorithm == "NONE": # pragma: no cover
@@ -219,10 +212,11 @@ def decode_token(token: str, verify_type: str | None = None) -> TokenPayload:
token_data = TokenPayload(**payload)
return token_data
except JWTError as e:
# Check if the error is due to an expired token
if "expired" in str(e).lower():
raise TokenExpiredError("Token has expired")
except ExpiredSignatureError:
raise TokenExpiredError("Token has expired")
except MissingRequiredClaimError as e:
raise TokenMissingClaimError(f"Token missing required claim: {e}")
except InvalidTokenError:
raise TokenInvalidError("Invalid authentication token")
except ValidationError:
raise TokenInvalidError("Invalid token payload")

View File

@@ -25,8 +25,8 @@ from datetime import UTC, datetime, timedelta
from typing import Any
from uuid import UUID
from jose import JWTError, jwt
from jose.exceptions import ExpiredSignatureError
import jwt
from jwt.exceptions import ExpiredSignatureError, InvalidTokenError
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.config import settings
@@ -704,7 +704,7 @@ async def revoke_token(
"Revoked refresh token via access token JTI %s...", jti[:8]
)
return True
except JWTError:
except InvalidTokenError:
pass
except Exception: # noqa: S110 - Intentional: invalid JWT not an error
pass
@@ -827,7 +827,7 @@ async def introspect_token(
}
except ExpiredSignatureError:
return {"active": False}
except JWTError:
except InvalidTokenError:
pass
except Exception: # noqa: S110 - Intentional: invalid JWT falls through to refresh token check
pass

View File

@@ -537,8 +537,9 @@ class OAuthService:
AuthenticationError: If verification fails
"""
import httpx
from jose import jwt as jose_jwt
from jose.exceptions import JWTError
import jwt as pyjwt
from jwt.algorithms import RSAAlgorithm
from jwt.exceptions import InvalidTokenError
try:
# Fetch Google's public keys (JWKS)
@@ -552,24 +553,27 @@ class OAuthService:
jwks = jwks_response.json()
# Get the key ID from the token header
unverified_header = jose_jwt.get_unverified_header(id_token)
unverified_header = pyjwt.get_unverified_header(id_token)
kid = unverified_header.get("kid")
if not kid:
raise AuthenticationError("ID token missing key ID (kid)")
# Find the matching public key
public_key = None
jwk_data = None
for key in jwks.get("keys", []):
if key.get("kid") == kid:
public_key = key
jwk_data = key
break
if not public_key:
if not jwk_data:
raise AuthenticationError("ID token signed with unknown key")
# Convert JWK to a public key object for PyJWT
public_key = RSAAlgorithm.from_jwk(jwk_data)
# Verify the token signature and decode claims
# jose library will verify signature against the JWK
payload = jose_jwt.decode(
# PyJWT will verify signature against the RSA public key
payload = pyjwt.decode(
id_token,
public_key,
algorithms=["RS256"], # Google uses RS256
@@ -597,7 +601,7 @@ class OAuthService:
logger.debug("Google ID token verified successfully")
return payload
except JWTError as e:
except InvalidTokenError as e:
logger.warning("Google ID token verification failed: %s", e)
raise AuthenticationError("Invalid ID token signature")
except httpx.HTTPError as e:

View File

@@ -79,12 +79,13 @@ This FastAPI backend application follows a **clean layered architecture** patter
### Authentication & Security
- **python-jose**: JWT token generation and validation
- Cryptographic signing
- **PyJWT**: JWT token generation and validation
- Cryptographic signing (HS256, RS256)
- Token expiration handling
- Claims validation
- JWK support for Google ID token verification
- **passlib + bcrypt**: Password hashing
- **bcrypt**: Password hashing
- Industry-standard bcrypt algorithm
- Configurable cost factor
- Salt generation

View File

@@ -43,9 +43,8 @@ dependencies = [
"pytz>=2024.1",
"pillow>=12.1.1",
"apscheduler==3.11.0",
# Security and authentication (pinned for reproducibility)
"python-jose==3.4.0",
"passlib==1.7.4",
# Security and authentication
"PyJWT>=2.9.0",
"bcrypt==4.2.1",
"cryptography>=46.0.5",
# OAuth authentication
@@ -158,7 +157,7 @@ unfixable = []
[tool.ruff.lint.per-file-ignores]
"app/alembic/env.py" = ["E402", "F403", "F405"] # Alembic requires specific import order
"app/alembic/versions/*.py" = ["E402"] # Migration files have specific structure
"tests/**/*.py" = ["S101", "N806", "B017", "N817", "S110", "ASYNC251", "RUF043", "T20"] # pytest: asserts, CamelCase fixtures, blind exceptions, try-pass patterns, async test helpers, and print for debugging are intentional
"tests/**/*.py" = ["S101", "N806", "B017", "N817", "ASYNC251", "RUF043", "T20"] # pytest: asserts, CamelCase fixtures, blind exceptions, async test helpers, and print for debugging are intentional
"app/models/__init__.py" = ["F401"] # __init__ files re-export modules
"app/models/base.py" = ["F401"] # Re-exports Base for use by other models
"app/utils/test_utils.py" = ["N806"] # SQLAlchemy session factories use CamelCase convention

View File

@@ -2,8 +2,8 @@
import uuid
from datetime import UTC, datetime, timedelta
import jwt
import pytest
from jose import jwt
from app.core.auth import (
TokenExpiredError,
@@ -215,6 +215,7 @@ class TestTokenDecoding:
payload = {
"sub": 123, # sub should be a string, not an integer
"exp": int((now + timedelta(minutes=30)).timestamp()),
"iat": int(now.timestamp()),
}
token = jwt.encode(payload, settings.SECRET_KEY, algorithm=settings.ALGORITHM)

View File

@@ -9,8 +9,8 @@ Critical security tests covering:
These tests cover critical security vulnerabilities that could be exploited.
"""
import jwt
import pytest
from jose import jwt
from app.core.auth import TokenInvalidError, create_access_token, decode_token
from app.core.config import settings
@@ -38,8 +38,8 @@ class TestJWTAlgorithmSecurityAttacks:
Attacker creates a token with "alg: none" to bypass signature verification.
NOTE: Lines 209 and 212 in auth.py are DEFENSIVE CODE that's never reached
because python-jose library rejects "none" algorithm tokens BEFORE we get there.
This is good for security! The library throws JWTError which becomes TokenInvalidError.
because PyJWT rejects "none" algorithm tokens BEFORE we get there.
This is good for security! The library throws InvalidTokenError which becomes TokenInvalidError.
This test verifies the overall protection works, even though our defensive
checks at lines 209-212 don't execute because the library catches it first.
@@ -108,36 +108,33 @@ class TestJWTAlgorithmSecurityAttacks:
Test that tokens with wrong algorithm are rejected.
Attack Scenario:
Attacker changes algorithm from HS256 to RS256, attempting to use
the public key as the HMAC secret. This could allow token forgery.
Attacker changes the "alg" header to RS256 while keeping an HMAC
signature, attempting algorithm confusion to forge tokens.
Reference: https://www.nccgroup.com/us/about-us/newsroom-and-events/blog/2019/january/jwt-algorithm-confusion/
NOTE: Like the "none" algorithm test, python-jose library catches this
before our defensive checks at line 212. This is good for security!
"""
import base64
import json
import time
now = int(time.time())
# Create a valid payload
payload = {"sub": "user123", "exp": now + 3600, "iat": now, "type": "access"}
# Encode with wrong algorithm (RS256 instead of HS256)
# This simulates an attacker trying algorithm substitution
wrong_algorithm = "RS256" if settings.ALGORITHM == "HS256" else "HS256"
# Hand-craft a token claiming RS256 in the header — PyJWT cannot encode
# RS256 with an HMAC key, so we craft the header manually (same technique
# as the "alg: none" tests) to produce a token that actually reaches decode_token.
header = {"alg": "RS256", "typ": "JWT"}
header_encoded = (
base64.urlsafe_b64encode(json.dumps(header).encode()).decode().rstrip("=")
)
payload_encoded = (
base64.urlsafe_b64encode(json.dumps(payload).encode()).decode().rstrip("=")
)
# Attach a fake signature to form a complete (but invalid) JWT
malicious_token = f"{header_encoded}.{payload_encoded}.fakesignature"
try:
malicious_token = jwt.encode(
payload, settings.SECRET_KEY, algorithm=wrong_algorithm
)
# Should reject the token (library catches mismatch)
with pytest.raises(TokenInvalidError):
decode_token(malicious_token)
except Exception:
# If encoding fails, that's also acceptable (library protection)
pass
with pytest.raises(TokenInvalidError):
decode_token(malicious_token)
def test_reject_hs384_when_hs256_expected(self):
"""
@@ -151,17 +148,11 @@ class TestJWTAlgorithmSecurityAttacks:
payload = {"sub": "user123", "exp": now + 3600, "iat": now, "type": "access"}
# Create token with HS384 instead of HS256
try:
malicious_token = jwt.encode(
payload, settings.SECRET_KEY, algorithm="HS384"
)
# Create token with HS384 instead of HS256 (HMAC key works with HS384)
malicious_token = jwt.encode(payload, settings.SECRET_KEY, algorithm="HS384")
with pytest.raises(TokenInvalidError):
decode_token(malicious_token)
except Exception:
# If encoding fails, that's also fine
pass
with pytest.raises(TokenInvalidError):
decode_token(malicious_token)
def test_valid_token_with_correct_algorithm_accepted(self):
"""

71
backend/uv.lock generated
View File

@@ -549,18 +549,6 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/e3/26/57c6fb270950d476074c087527a558ccb6f4436657314bfb6cdf484114c4/docker-7.1.0-py3-none-any.whl", hash = "sha256:c96b93b7f0a746f9e77d325bcfb87422a3d8bd4f03136ae8a85b37f1898d5fc0", size = 147774, upload-time = "2024-05-23T11:13:55.01Z" },
]
[[package]]
name = "ecdsa"
version = "0.19.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "six" },
]
sdist = { url = "https://files.pythonhosted.org/packages/c0/1f/924e3caae75f471eae4b26bd13b698f6af2c44279f67af317439c2f4c46a/ecdsa-0.19.1.tar.gz", hash = "sha256:478cba7b62555866fcb3bb3fe985e06decbdb68ef55713c4e5ab98c57d508e61", size = 201793, upload-time = "2025-03-13T11:52:43.25Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/cb/a3/460c57f094a4a165c84a1341c373b0a4f5ec6ac244b998d5021aade89b77/ecdsa-0.19.1-py2.py3-none-any.whl", hash = "sha256:30638e27cf77b7e15c4c4cc1973720149e1033827cfd00661ca5c8cc0cdb24c3", size = 150607, upload-time = "2025-03-13T11:52:41.757Z" },
]
[[package]]
name = "email-validator"
version = "2.3.0"
@@ -599,13 +587,12 @@ dependencies = [
{ name = "fastapi" },
{ name = "fastapi-utils" },
{ name = "httpx" },
{ name = "passlib" },
{ name = "pillow" },
{ name = "psycopg2-binary" },
{ name = "pydantic" },
{ name = "pydantic-settings" },
{ name = "pyjwt" },
{ name = "python-dotenv" },
{ name = "python-jose" },
{ name = "python-multipart" },
{ name = "pytz" },
{ name = "slowapi" },
@@ -653,7 +640,6 @@ requires-dist = [
{ name = "fastapi-utils", specifier = "==0.8.0" },
{ name = "freezegun", marker = "extra == 'dev'", specifier = "~=1.5.1" },
{ name = "httpx", specifier = ">=0.27.0" },
{ name = "passlib", specifier = "==1.7.4" },
{ name = "pillow", specifier = ">=12.1.1" },
{ name = "pip-audit", marker = "extra == 'dev'", specifier = ">=2.7.0" },
{ name = "pip-licenses", marker = "extra == 'dev'", specifier = ">=4.0.0" },
@@ -661,13 +647,13 @@ requires-dist = [
{ name = "psycopg2-binary", specifier = ">=2.9.9" },
{ name = "pydantic", specifier = ">=2.10.6" },
{ name = "pydantic-settings", specifier = ">=2.2.1" },
{ name = "pyjwt", specifier = ">=2.9.0" },
{ name = "pyright", marker = "extra == 'dev'", specifier = ">=1.1.390" },
{ name = "pytest", marker = "extra == 'dev'", specifier = ">=8.0.0" },
{ name = "pytest-asyncio", marker = "extra == 'dev'", specifier = ">=0.23.5" },
{ name = "pytest-cov", marker = "extra == 'dev'", specifier = ">=4.1.0" },
{ name = "pytest-xdist", marker = "extra == 'dev'", specifier = ">=3.8.0" },
{ name = "python-dotenv", specifier = ">=1.0.1" },
{ name = "python-jose", specifier = "==3.4.0" },
{ name = "python-multipart", specifier = ">=0.0.22" },
{ name = "pytz", specifier = ">=2024.1" },
{ name = "requests", marker = "extra == 'dev'", specifier = ">=2.32.0" },
@@ -1177,15 +1163,6 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/20/12/38679034af332785aac8774540895e234f4d07f7545804097de4b666afd8/packaging-25.0-py3-none-any.whl", hash = "sha256:29572ef2b1f17581046b3a2227d5c611fb25ec70ca1ba8554b24b0e69331a484", size = 66469, upload-time = "2025-04-19T11:48:57.875Z" },
]
[[package]]
name = "passlib"
version = "1.7.4"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/b6/06/9da9ee59a67fae7761aab3ccc84fa4f3f33f125b370f1ccdb915bf967c11/passlib-1.7.4.tar.gz", hash = "sha256:defd50f72b65c5402ab2c573830a6978e5f202ad0d984793c8dde2c4152ebe04", size = 689844, upload-time = "2020-10-08T19:00:52.121Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/3b/a4/ab6b7589382ca3df236e03faa71deac88cae040af60c071a78d254a62172/passlib-1.7.4-py2.py3-none-any.whl", hash = "sha256:aa6bca462b8d8bda89c70b382f0c298a20b5560af6cbfa2dce410c0a2fb669f1", size = 525554, upload-time = "2020-10-08T19:00:49.856Z" },
]
[[package]]
name = "pillow"
version = "12.1.1"
@@ -1435,15 +1412,6 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/9b/bf/7595e817906a29453ba4d99394e781b6fabe55d21f3c15d240f85dd06bb1/py_serializable-2.1.0-py3-none-any.whl", hash = "sha256:b56d5d686b5a03ba4f4db5e769dc32336e142fc3bd4d68a8c25579ebb0a67304", size = 23045, upload-time = "2025-07-21T09:56:46.848Z" },
]
[[package]]
name = "pyasn1"
version = "0.4.8"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/a4/db/fffec68299e6d7bad3d504147f9094830b704527a7fc098b721d38cc7fa7/pyasn1-0.4.8.tar.gz", hash = "sha256:aef77c9fb94a3ac588e87841208bdec464471d9871bd5050a287cc9a475cd0ba", size = 146820, upload-time = "2019-11-16T17:27:38.772Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/62/1e/a94a8d635fa3ce4cfc7f506003548d0a2447ae76fd5ca53932970fe3053f/pyasn1-0.4.8-py2.py3-none-any.whl", hash = "sha256:39c7e2ec30515947ff4e87fb6f456dfc6e84857d34be479c9d4a4ba4bf46aa5d", size = 77145, upload-time = "2019-11-16T17:27:11.07Z" },
]
[[package]]
name = "pycparser"
version = "2.23"
@@ -1562,6 +1530,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/c7/21/705964c7812476f378728bdf590ca4b771ec72385c533964653c68e86bdc/pygments-2.19.2-py3-none-any.whl", hash = "sha256:86540386c03d588bb81d44bc3928634ff26449851e99741617ecb9037ee5ec0b", size = 1225217, upload-time = "2025-06-21T13:39:07.939Z" },
]
[[package]]
name = "pyjwt"
version = "2.11.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/5c/5a/b46fa56bf322901eee5b0454a34343cdbdae202cd421775a8ee4e42fd519/pyjwt-2.11.0.tar.gz", hash = "sha256:35f95c1f0fbe5d5ba6e43f00271c275f7a1a4db1dab27bf708073b75318ea623", size = 98019, upload-time = "2026-01-30T19:59:55.694Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/6f/01/c26ce75ba460d5cd503da9e13b21a33804d38c2165dec7b716d06b13010c/pyjwt-2.11.0-py3-none-any.whl", hash = "sha256:94a6bde30eb5c8e04fee991062b534071fd1439ef58d2adc9ccb823e7bcd0469", size = 28224, upload-time = "2026-01-30T19:59:54.539Z" },
]
[[package]]
name = "pyparsing"
version = "3.3.2"
@@ -1696,20 +1673,6 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/14/1b/a298b06749107c305e1fe0f814c6c74aea7b2f1e10989cb30f544a1b3253/python_dotenv-1.2.1-py3-none-any.whl", hash = "sha256:b81ee9561e9ca4004139c6cbba3a238c32b03e4894671e181b671e8cb8425d61", size = 21230, upload-time = "2025-10-26T15:12:09.109Z" },
]
[[package]]
name = "python-jose"
version = "3.4.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "ecdsa" },
{ name = "pyasn1" },
{ name = "rsa" },
]
sdist = { url = "https://files.pythonhosted.org/packages/8e/a0/c49687cf40cb6128ea4e0559855aff92cd5ebd1a60a31c08526818c0e51e/python-jose-3.4.0.tar.gz", hash = "sha256:9a9a40f418ced8ecaf7e3b28d69887ceaa76adad3bcaa6dae0d9e596fec1d680", size = 92145, upload-time = "2025-02-18T17:26:41.985Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/63/b0/2586ea6b6fd57a994ece0b56418cbe93fff0efb85e2c9eb6b0caf24a4e37/python_jose-3.4.0-py2.py3-none-any.whl", hash = "sha256:9c9f616819652d109bd889ecd1e15e9a162b9b94d682534c9c2146092945b78f", size = 34616, upload-time = "2025-02-18T17:26:40.826Z" },
]
[[package]]
name = "python-multipart"
version = "0.0.22"
@@ -1934,18 +1897,6 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/c9/7f/1a65ae870bc9d0576aebb0c501ea5dccf1ae2178fe2821042150ebd2e707/rpds_py-0.29.0-cp314-cp314t-win_amd64.whl", hash = "sha256:2023473f444752f0f82a58dfcbee040d0a1b3d1b3c2ec40e884bd25db6d117d2", size = 225919, upload-time = "2025-11-16T14:50:14.734Z" },
]
[[package]]
name = "rsa"
version = "4.9.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "pyasn1" },
]
sdist = { url = "https://files.pythonhosted.org/packages/da/8a/22b7beea3ee0d44b1916c0c1cb0ee3af23b700b6da9f04991899d0c555d4/rsa-4.9.1.tar.gz", hash = "sha256:e7bdbfdb5497da4c07dfd35530e1a902659db6ff241e39d9953cad06ebd0ae75", size = 29034, upload-time = "2025-04-16T09:51:18.218Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/64/8d/0133e4eb4beed9e425d9a98ed6e081a55d195481b7632472be1af08d2f6b/rsa-4.9.1-py3-none-any.whl", hash = "sha256:68635866661c6836b8d39430f97a996acbd61bfa49406748ea243539fe239762", size = 34696, upload-time = "2025-04-16T09:51:17.142Z" },
]
[[package]]
name = "ruff"
version = "0.14.4"