Compare commits
3 Commits
0553a1fc53
...
f8aafb250d
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f8aafb250d | ||
|
|
4385d20ca6 | ||
|
|
1a36907f10 |
@@ -86,14 +86,12 @@ 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:
|
||||||
@echo "📜 Checking dependency license compliance..."
|
@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!"
|
@echo "✅ All dependency licenses are compliant!"
|
||||||
|
|
||||||
audit: dep-audit license-check
|
audit: dep-audit license-check
|
||||||
|
|||||||
@@ -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
|
raise TokenExpiredError("Token has expired")
|
||||||
if "expired" in str(e).lower():
|
except MissingRequiredClaimError as e:
|
||||||
raise TokenExpiredError("Token has expired")
|
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")
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -158,7 +157,7 @@ unfixable = []
|
|||||||
[tool.ruff.lint.per-file-ignores]
|
[tool.ruff.lint.per-file-ignores]
|
||||||
"app/alembic/env.py" = ["E402", "F403", "F405"] # Alembic requires specific import order
|
"app/alembic/env.py" = ["E402", "F403", "F405"] # Alembic requires specific import order
|
||||||
"app/alembic/versions/*.py" = ["E402"] # Migration files have specific structure
|
"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/__init__.py" = ["F401"] # __init__ files re-export modules
|
||||||
"app/models/base.py" = ["F401"] # Re-exports Base for use by other models
|
"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
|
"app/utils/test_utils.py" = ["N806"] # SQLAlchemy session factories use CamelCase convention
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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"}
|
||||||
|
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:
|
with pytest.raises(TokenInvalidError):
|
||||||
malicious_token = jwt.encode(
|
decode_token(malicious_token)
|
||||||
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
|
|
||||||
|
|
||||||
def test_reject_hs384_when_hs256_expected(self):
|
def test_reject_hs384_when_hs256_expected(self):
|
||||||
"""
|
"""
|
||||||
@@ -151,17 +148,11 @@ class TestJWTAlgorithmSecurityAttacks:
|
|||||||
|
|
||||||
payload = {"sub": "user123", "exp": now + 3600, "iat": now, "type": "access"}
|
payload = {"sub": "user123", "exp": now + 3600, "iat": now, "type": "access"}
|
||||||
|
|
||||||
# Create token with HS384 instead of HS256
|
# Create token with HS384 instead of HS256 (HMAC key works with HS384)
|
||||||
try:
|
malicious_token = jwt.encode(payload, settings.SECRET_KEY, algorithm="HS384")
|
||||||
malicious_token = jwt.encode(
|
|
||||||
payload, settings.SECRET_KEY, algorithm="HS384"
|
|
||||||
)
|
|
||||||
|
|
||||||
with pytest.raises(TokenInvalidError):
|
with pytest.raises(TokenInvalidError):
|
||||||
decode_token(malicious_token)
|
decode_token(malicious_token)
|
||||||
except Exception:
|
|
||||||
# If encoding fails, that's also fine
|
|
||||||
pass
|
|
||||||
|
|
||||||
def test_valid_token_with_correct_algorithm_accepted(self):
|
def test_valid_token_with_correct_algorithm_accepted(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" },
|
{ 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"
|
||||||
|
|||||||
Reference in New Issue
Block a user