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:
@@ -86,9 +86,7 @@ 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:
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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):
|
||||
"""
|
||||
|
||||
71
backend/uv.lock
generated
71
backend/uv.lock
generated
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user