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).
This commit is contained in:
2026-03-01 14:02:04 +01:00
parent 0553a1fc53
commit 1a36907f10
9 changed files with 84 additions and 139 deletions

View File

@@ -86,9 +86,7 @@ validate: lint format-check type-check
dep-audit: dep-audit:
@echo "🔒 Scanning dependencies for known vulnerabilities..." @echo "🔒 Scanning dependencies for known vulnerabilities..."
@# CVE-2024-23342: ecdsa timing attack via python-jose (transitive). No fix available. @uv run pip-audit --desc --skip-editable
@# 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
@echo "✅ No known vulnerabilities found!" @echo "✅ No known vulnerabilities found!"
license-check: license-check:

View File

@@ -1,23 +1,21 @@
import asyncio import asyncio
import logging
import uuid import uuid
from datetime import UTC, datetime, timedelta from datetime import UTC, datetime, timedelta
from functools import partial from functools import partial
from typing import Any from typing import Any
from jose import JWTError, jwt import bcrypt
from passlib.context import CryptContext import jwt
from jwt.exceptions import (
ExpiredSignatureError,
InvalidTokenError,
MissingRequiredClaimError,
)
from pydantic import ValidationError from pydantic import ValidationError
from app.core.config import settings from app.core.config import settings
from app.schemas.users import TokenData, TokenPayload 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 # Custom exceptions for auth
class AuthError(Exception): class AuthError(Exception):
@@ -37,13 +35,16 @@ class TokenMissingClaimError(AuthError):
def verify_password(plain_password: str, hashed_password: str) -> bool: def verify_password(plain_password: str, hashed_password: str) -> bool:
"""Verify a password against a hash.""" """Verify a password against a bcrypt hash."""
return pwd_context.verify(plain_password, hashed_password) return bcrypt.checkpw(
plain_password.encode("utf-8"), hashed_password.encode("utf-8")
)
def get_password_hash(password: str) -> str: def get_password_hash(password: str) -> str:
"""Generate a password hash.""" """Generate a bcrypt password hash."""
return pwd_context.hash(password) 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: 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: Returns:
True if password matches, False otherwise True if password matches, False otherwise
""" """
loop = asyncio.get_event_loop() loop = asyncio.get_running_loop()
return await loop.run_in_executor( 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: Returns:
Hashed password string Hashed password string
""" """
loop = asyncio.get_event_loop() loop = asyncio.get_running_loop()
return await loop.run_in_executor(None, pwd_context.hash, password) return await loop.run_in_executor(None, get_password_hash, password)
def create_access_token( def create_access_token(
@@ -121,11 +122,7 @@ def create_access_token(
to_encode.update(claims) to_encode.update(claims)
# Create the JWT # Create the JWT
encoded_jwt = jwt.encode( return jwt.encode(to_encode, settings.SECRET_KEY, algorithm=settings.ALGORITHM)
to_encode, settings.SECRET_KEY, algorithm=settings.ALGORITHM
)
return encoded_jwt
def create_refresh_token( def create_refresh_token(
@@ -154,11 +151,7 @@ def create_refresh_token(
"type": "refresh", "type": "refresh",
} }
encoded_jwt = jwt.encode( return jwt.encode(to_encode, settings.SECRET_KEY, algorithm=settings.ALGORITHM)
to_encode, settings.SECRET_KEY, algorithm=settings.ALGORITHM
)
return encoded_jwt
def decode_token(token: str, verify_type: str | None = None) -> TokenPayload: 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 # Reject weak or unexpected algorithms
# NOTE: These are defensive checks that provide defense-in-depth. # 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. # 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) # Coverage: Marked as pragma since library catches first (see tests/core/test_auth_security.py)
if token_algorithm == "NONE": # pragma: no cover 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) token_data = TokenPayload(**payload)
return token_data return token_data
except JWTError as e: except ExpiredSignatureError:
# Check if the error is due to an expired token
if "expired" in str(e).lower():
raise TokenExpiredError("Token has expired") raise TokenExpiredError("Token has expired")
except MissingRequiredClaimError as e:
raise TokenMissingClaimError(f"Token missing required claim: {e}")
except InvalidTokenError:
raise TokenInvalidError("Invalid authentication token") raise TokenInvalidError("Invalid authentication token")
except ValidationError: except ValidationError:
raise TokenInvalidError("Invalid token payload") raise TokenInvalidError("Invalid token payload")

View File

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

View File

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

View File

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

View File

@@ -43,9 +43,8 @@ dependencies = [
"pytz>=2024.1", "pytz>=2024.1",
"pillow>=12.1.1", "pillow>=12.1.1",
"apscheduler==3.11.0", "apscheduler==3.11.0",
# Security and authentication (pinned for reproducibility) # Security and authentication
"python-jose==3.4.0", "PyJWT>=2.9.0",
"passlib==1.7.4",
"bcrypt==4.2.1", "bcrypt==4.2.1",
"cryptography>=46.0.5", "cryptography>=46.0.5",
# OAuth authentication # OAuth authentication

View File

@@ -2,8 +2,8 @@
import uuid import uuid
from datetime import UTC, datetime, timedelta from datetime import UTC, datetime, timedelta
import jwt
import pytest import pytest
from jose import jwt
from app.core.auth import ( from app.core.auth import (
TokenExpiredError, TokenExpiredError,
@@ -215,6 +215,7 @@ class TestTokenDecoding:
payload = { payload = {
"sub": 123, # sub should be a string, not an integer "sub": 123, # sub should be a string, not an integer
"exp": int((now + timedelta(minutes=30)).timestamp()), "exp": int((now + timedelta(minutes=30)).timestamp()),
"iat": int(now.timestamp()),
} }
token = jwt.encode(payload, settings.SECRET_KEY, algorithm=settings.ALGORITHM) 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. These tests cover critical security vulnerabilities that could be exploited.
""" """
import jwt
import pytest import pytest
from jose import jwt
from app.core.auth import TokenInvalidError, create_access_token, decode_token from app.core.auth import TokenInvalidError, create_access_token, decode_token
from app.core.config import settings from app.core.config import settings
@@ -38,8 +38,8 @@ class TestJWTAlgorithmSecurityAttacks:
Attacker creates a token with "alg: none" to bypass signature verification. 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 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. because PyJWT rejects "none" algorithm tokens BEFORE we get there.
This is good for security! The library throws JWTError which becomes TokenInvalidError. This is good for security! The library throws InvalidTokenError which becomes TokenInvalidError.
This test verifies the overall protection works, even though our defensive 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. 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. Test that tokens with wrong algorithm are rejected.
Attack Scenario: Attack Scenario:
Attacker changes algorithm from HS256 to RS256, attempting to use Attacker changes the "alg" header to RS256 while keeping an HMAC
the public key as the HMAC secret. This could allow token forgery. signature, attempting algorithm confusion to forge tokens.
Reference: https://www.nccgroup.com/us/about-us/newsroom-and-events/blog/2019/january/jwt-algorithm-confusion/ 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 import time
now = int(time.time()) now = int(time.time())
# Create a valid payload
payload = {"sub": "user123", "exp": now + 3600, "iat": now, "type": "access"} payload = {"sub": "user123", "exp": now + 3600, "iat": now, "type": "access"}
# Encode with wrong algorithm (RS256 instead of HS256) # Hand-craft a token claiming RS256 in the header — PyJWT cannot encode
# This simulates an attacker trying algorithm substitution # RS256 with an HMAC key, so we craft the header manually (same technique
wrong_algorithm = "RS256" if settings.ALGORITHM == "HS256" else "HS256" # as the "alg: none" tests) to produce a token that actually reaches decode_token.
header = {"alg": "RS256", "typ": "JWT"}
try: header_encoded = (
malicious_token = jwt.encode( base64.urlsafe_b64encode(json.dumps(header).encode()).decode().rstrip("=")
payload, settings.SECRET_KEY, algorithm=wrong_algorithm
) )
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"
# Should reject the token (library catches mismatch)
with pytest.raises(TokenInvalidError): with pytest.raises(TokenInvalidError):
decode_token(malicious_token) decode_token(malicious_token)
except Exception:
# If encoding fails, that's also acceptable (library protection)
pass
def test_reject_hs384_when_hs256_expected(self): def test_reject_hs384_when_hs256_expected(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" }, { 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]] [[package]]
name = "email-validator" name = "email-validator"
version = "2.3.0" version = "2.3.0"
@@ -599,13 +587,12 @@ dependencies = [
{ name = "fastapi" }, { name = "fastapi" },
{ name = "fastapi-utils" }, { name = "fastapi-utils" },
{ name = "httpx" }, { name = "httpx" },
{ name = "passlib" },
{ name = "pillow" }, { name = "pillow" },
{ name = "psycopg2-binary" }, { name = "psycopg2-binary" },
{ name = "pydantic" }, { name = "pydantic" },
{ name = "pydantic-settings" }, { name = "pydantic-settings" },
{ name = "pyjwt" },
{ name = "python-dotenv" }, { name = "python-dotenv" },
{ name = "python-jose" },
{ name = "python-multipart" }, { name = "python-multipart" },
{ name = "pytz" }, { name = "pytz" },
{ name = "slowapi" }, { name = "slowapi" },
@@ -653,7 +640,6 @@ requires-dist = [
{ name = "fastapi-utils", specifier = "==0.8.0" }, { name = "fastapi-utils", specifier = "==0.8.0" },
{ name = "freezegun", marker = "extra == 'dev'", specifier = "~=1.5.1" }, { name = "freezegun", marker = "extra == 'dev'", specifier = "~=1.5.1" },
{ name = "httpx", specifier = ">=0.27.0" }, { name = "httpx", specifier = ">=0.27.0" },
{ name = "passlib", specifier = "==1.7.4" },
{ name = "pillow", specifier = ">=12.1.1" }, { name = "pillow", specifier = ">=12.1.1" },
{ name = "pip-audit", marker = "extra == 'dev'", specifier = ">=2.7.0" }, { name = "pip-audit", marker = "extra == 'dev'", specifier = ">=2.7.0" },
{ name = "pip-licenses", marker = "extra == 'dev'", specifier = ">=4.0.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 = "psycopg2-binary", specifier = ">=2.9.9" },
{ name = "pydantic", specifier = ">=2.10.6" }, { name = "pydantic", specifier = ">=2.10.6" },
{ name = "pydantic-settings", specifier = ">=2.2.1" }, { name = "pydantic-settings", specifier = ">=2.2.1" },
{ name = "pyjwt", specifier = ">=2.9.0" },
{ name = "pyright", marker = "extra == 'dev'", specifier = ">=1.1.390" }, { name = "pyright", marker = "extra == 'dev'", specifier = ">=1.1.390" },
{ name = "pytest", marker = "extra == 'dev'", specifier = ">=8.0.0" }, { name = "pytest", marker = "extra == 'dev'", specifier = ">=8.0.0" },
{ name = "pytest-asyncio", marker = "extra == 'dev'", specifier = ">=0.23.5" }, { name = "pytest-asyncio", marker = "extra == 'dev'", specifier = ">=0.23.5" },
{ name = "pytest-cov", marker = "extra == 'dev'", specifier = ">=4.1.0" }, { name = "pytest-cov", marker = "extra == 'dev'", specifier = ">=4.1.0" },
{ name = "pytest-xdist", marker = "extra == 'dev'", specifier = ">=3.8.0" }, { name = "pytest-xdist", marker = "extra == 'dev'", specifier = ">=3.8.0" },
{ name = "python-dotenv", specifier = ">=1.0.1" }, { name = "python-dotenv", specifier = ">=1.0.1" },
{ name = "python-jose", specifier = "==3.4.0" },
{ name = "python-multipart", specifier = ">=0.0.22" }, { name = "python-multipart", specifier = ">=0.0.22" },
{ name = "pytz", specifier = ">=2024.1" }, { name = "pytz", specifier = ">=2024.1" },
{ name = "requests", marker = "extra == 'dev'", specifier = ">=2.32.0" }, { 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" }, { 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]] [[package]]
name = "pillow" name = "pillow"
version = "12.1.1" 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" }, { 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]] [[package]]
name = "pycparser" name = "pycparser"
version = "2.23" 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" }, { 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]] [[package]]
name = "pyparsing" name = "pyparsing"
version = "3.3.2" 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" }, { 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]] [[package]]
name = "python-multipart" name = "python-multipart"
version = "0.0.22" 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" }, { 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]] [[package]]
name = "ruff" name = "ruff"
version = "0.14.4" version = "0.14.4"