fix: JWKS-проверяющий — алгоритм по типу ключа, без кэша ключей по kid; email в нижний регистр

I-1: в jwt.decode передаётся PyJWK (а не голый ключ), рассогласование alg и типа ключа даёт 401,
а не TypeError/500; страховка на (TypeError, ValueError).
I-2: cache_keys=False — кэш ключей по kid не имеет срока жизни и держал бы отозванный ключ;
остаётся кэш набора JWKS на 300 с.
R-17: Claims.email = strip().lower().
R-18: запредельный exp не даёт 500; пустой sub отвергается; kid из заголовка пишется в лог через repr и усекается.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
Andrey Epifancev 2026-10-03 22:01:33 +04:00
parent d7469f31bc
commit 5ed52a7239
2 changed files with 190 additions and 45 deletions

View File

@ -38,9 +38,12 @@ DEV_ALGORITHM = "HS256"
JWKS_LEEWAY_SECONDS = 10 JWKS_LEEWAY_SECONDS = 10
# Срок жизни dev-токена (требование спеки authn-authz) # Срок жизни dev-токена (требование спеки authn-authz)
DEV_TOKEN_TTL = timedelta(hours=8) DEV_TOKEN_TTL = timedelta(hours=8)
# Время жизни кэша набора ключей JWKS (PyJWKClient), секунды # Время жизни кэша набора ключей JWKS (PyJWKClient), секунды. Кэш отдельных ключей по kid (cache_keys) выключен
# намеренно: он без срока жизни и оставил бы отозванный ключ действующим до перезапуска процесса.
JWKS_CACHE_LIFESPAN_SECONDS = 300 JWKS_CACHE_LIFESPAN_SECONDS = 300
JWKS_FETCH_TIMEOUT_SECONDS = 10 JWKS_FETCH_TIMEOUT_SECONDS = 10
# Предел длины подконтрольного клиенту текста (kid и т.п.) в строке лога
_LOG_TEXT_LIMIT = 200
class AuthError(Exception): class AuthError(Exception):
@ -75,7 +78,7 @@ class TokenVerifier(Protocol):
class _SigningKeyProvider(Protocol): class _SigningKeyProvider(Protocol):
"""Источник ключа проверки подписи по токену (интерфейс :class:`jwt.PyJWKClient`).""" """Источник ключа проверки подписи по токену (интерфейс :class:`jwt.PyJWKClient`)."""
def get_signing_key_from_jwt(self, token: str) -> Any: ... def get_signing_key_from_jwt(self, token: str) -> jwt.PyJWK: ...
# Порядок важен: более конкретные классы исключений PyJWT — раньше общих # Порядок важен: более конкретные классы исключений PyJWT — раньше общих
@ -99,21 +102,30 @@ def _to_auth_error(exc: jwt.PyJWTError) -> AuthError:
return AuthError(f"в токене нет обязательного поля «{exc.claim}»") return AuthError(f"в токене нет обязательного поля «{exc.claim}»")
if isinstance(exc, jwt.PyJWKClientError): if isinstance(exc, jwt.PyJWKClientError):
# сбой получения ключей (сеть, неизвестный kid) — деталь только в лог, не в ответ # сбой получения ключей (сеть, неизвестный kid) — деталь только в лог, не в ответ
logger.warning("Не удалось получить ключ проверки подписи JWT: {}", exc) # сообщение может содержать kid из заголовка токена (его присылает клиент): repr экранирует переводы строк,
# усечение ограничивает размер
logger.warning("Не удалось получить ключ проверки подписи JWT: {}", repr(str(exc))[:_LOG_TEXT_LIMIT])
return AuthError("не удалось получить ключ проверки подписи токена") return AuthError("не удалось получить ключ проверки подписи токена")
return AuthError("недействительный токен") return AuthError("недействительный токен")
def _claims_from_payload(payload: dict[str, Any], email_claim: str) -> Claims: def _claims_from_payload(payload: dict[str, Any], email_claim: str) -> Claims:
"""Собирает :class:`Claims` из проверенной полезной нагрузки; ``exp`` и ``sub`` уже проверены на наличие.""" """Собирает :class:`Claims` из проверенной полезной нагрузки; ``exp`` и ``sub`` уже проверены на наличие.
Email приводится к нижнему регистру (``strip().lower()``): сопоставление с ``app_user.email`` — без учёта регистра.
"""
email = payload.get(email_claim) email = payload.get(email_claim)
if not isinstance(email, str) or not email.strip(): if not isinstance(email, str) or not email.strip():
raise AuthError("в токене нет email") raise AuthError("в токене нет email")
return Claims( subject = payload["sub"]
subject=str(payload["sub"]), if not isinstance(subject, str) or not subject.strip():
email=email.strip(), raise AuthError("в токене нет обязательного поля «sub»")
exp=datetime.fromtimestamp(payload["exp"], tz=UTC), try:
) exp = datetime.fromtimestamp(payload["exp"], tz=UTC)
except (TypeError, ValueError, OverflowError, OSError) as exc:
# нечисловой или запредельный exp, прошедший проверку подписи, не должен давать 500
raise AuthError("недействительный токен") from exc
return Claims(subject=subject, email=email.strip().lower(), exp=exp)
class DevHs256Verifier: class DevHs256Verifier:
@ -159,7 +171,7 @@ def _load_static_key(pem: str) -> tuple[Any, tuple[str, ...]]:
class JwksVerifier: class JwksVerifier:
"""Проверяющий токены РАИСА: RS256/ES256 по статическому публичному ключу либо по JWKS (кэш ключей). """Проверяющий токены РАИСА: RS256/ES256 по статическому публичному ключу либо по JWKS (кэш набора ключей).
``iss`` проверяется, только если задан ``issuer``; ``aud`` — только если задан ``audience``. ``exp`` и ``sub`` ``iss`` проверяется, только если задан ``issuer``; ``aud`` — только если задан ``audience``. ``exp`` и ``sub``
обязательны всегда. ``jwk_client`` — внедряемый источник ключей (тесты); по умолчанию :class:`jwt.PyJWKClient`. обязательны всегда. ``jwk_client`` — внедряемый источник ключей (тесты); по умолчанию :class:`jwt.PyJWKClient`.
@ -189,7 +201,7 @@ class JwksVerifier:
elif jwks_url: elif jwks_url:
self._client = PyJWKClient( self._client = PyJWKClient(
jwks_url, jwks_url,
cache_keys=True, cache_keys=False,
cache_jwk_set=True, cache_jwk_set=True,
lifespan=JWKS_CACHE_LIFESPAN_SECONDS, lifespan=JWKS_CACHE_LIFESPAN_SECONDS,
timeout=JWKS_FETCH_TIMEOUT_SECONDS, timeout=JWKS_FETCH_TIMEOUT_SECONDS,
@ -204,7 +216,9 @@ class JwksVerifier:
return self._static_key, list(self._static_algorithms) return self._static_key, list(self._static_algorithms)
# PyJWKClient ходит в сеть блокирующе — выносим из цикла событий # PyJWKClient ходит в сеть блокирующе — выносим из цикла событий
signing_key = await asyncio.to_thread(self._client.get_signing_key_from_jwt, token) signing_key = await asyncio.to_thread(self._client.get_signing_key_from_jwt, token)
return signing_key.key, list(JWKS_ALGORITHMS) # Отдаём сам PyJWK, а не signing_key.key: PyJWT тогда сверяет алгоритм из заголовка токена с типом ключа
# (RSA-ключ с ES256 и наоборот — InvalidAlgorithmError, а не TypeError из недр библиотеки)
return signing_key, list(JWKS_ALGORITHMS)
async def verify(self, token: str) -> Claims: async def verify(self, token: str) -> Claims:
try: try:
@ -220,6 +234,9 @@ class JwksVerifier:
) )
except jwt.PyJWTError as exc: except jwt.PyJWTError as exc:
raise _to_auth_error(exc) from exc raise _to_auth_error(exc) from exc
except (TypeError, ValueError) as exc:
# страховка: несовместимые ключ и алгоритм не должны превращаться в 500
raise AuthError("недействительный токен") from exc
return _claims_from_payload(payload, self._email_claim) return _claims_from_payload(payload, self._email_claim)

View File

@ -6,6 +6,8 @@ import hmac
import json import json
import os import os
import threading import threading
import time
from collections.abc import Callable, Iterator
from datetime import UTC, datetime, timedelta from datetime import UTC, datetime, timedelta
from http.server import BaseHTTPRequestHandler, HTTPServer from http.server import BaseHTTPRequestHandler, HTTPServer
from types import SimpleNamespace from types import SimpleNamespace
@ -15,9 +17,11 @@ import jwt
import pytest import pytest
from cryptography.hazmat.primitives import serialization from cryptography.hazmat.primitives import serialization
from cryptography.hazmat.primitives.asymmetric import ec, rsa from cryptography.hazmat.primitives.asymmetric import ec, rsa
from jwt.exceptions import PyJWKClientConnectionError from jwt import PyJWKClient
from jwt.exceptions import PyJWKClientConnectionError, PyJWKClientError
from src.core.auth import ( from src.core.auth import (
JWKS_CACHE_LIFESPAN_SECONDS,
AuthError, AuthError,
Claims, Claims,
DevHs256Verifier, DevHs256Verifier,
@ -31,6 +35,8 @@ from src.core.config import Settings
DEV_SECRET = "dev-secret-for-unit-tests-0123456789abcdef" DEV_SECRET = "dev-secret-for-unit-tests-0123456789abcdef"
OTHER_SECRET = "another-secret-for-unit-tests-0123456789" OTHER_SECRET = "another-secret-for-unit-tests-0123456789"
EMAIL = "ka_rf@armka.local" EMAIL = "ka_rf@armka.local"
SET_CACHE_MAX_SECONDS = 300 # кэш набора ключей JWKS не дольше 5 минут
LOG_LINE_MAX = 400
class _LogSpy: class _LogSpy:
@ -209,6 +215,35 @@ def test_dev_empty_secret_forbidden() -> None:
DevHs256Verifier("") DevHs256Verifier("")
# --- Нормализация email и защита от запредельных claims (R-17, R-18) -------------------------------------------
async def test_email_is_lowercased_and_stripped(dev: DevHs256Verifier) -> None:
token = jwt.encode(_payload(email=" Ka_RF@ARMKA.Local "), DEV_SECRET, algorithm="HS256")
assert (await dev.verify(token)).email == "ka_rf@armka.local"
async def test_email_is_lowercased_in_jwks(asym_pub_priv: tuple[str, str, str]) -> None:
alg, priv, pub = asym_pub_priv
token = jwt.encode(_payload(email="Ivan.Petrov@RSHB.ru"), priv, algorithm=alg)
assert (await JwksVerifier(public_key=pub).verify(token)).email == "ivan.petrov@rshb.ru"
@pytest.mark.parametrize("sub", ["", " "])
async def test_empty_sub_rejected(dev: DevHs256Verifier, sub: str) -> None:
token = jwt.encode(_payload(sub=sub), DEV_SECRET, algorithm="HS256")
with pytest.raises(AuthError, match="sub"):
await dev.verify(token)
@pytest.mark.parametrize("exp", ["9999999999", 10**12, 10**30, -(10**12), 1.5e300, True, [1]])
async def test_garbage_exp_is_auth_error_not_500(dev: DevHs256Verifier, exp: Any) -> None:
"""Запредельный или нечисловой exp (даже с верной подписью) — AuthError, а не OverflowError/ValueError/TypeError."""
token = jwt.encode(_payload(exp=exp), DEV_SECRET, algorithm="HS256")
with pytest.raises(AuthError):
await dev.verify(token)
# --- JwksVerifier (статический публичный ключ) ------------------------------------------------------------------- # --- JwksVerifier (статический публичный ключ) -------------------------------------------------------------------
@ -230,6 +265,11 @@ def asym(
) )
@pytest.fixture
def asym_pub_priv(asym: SimpleNamespace) -> tuple[str, str, str]:
return asym.alg, asym.priv, asym.pub
async def test_jwks_static_valid(asym: SimpleNamespace) -> None: async def test_jwks_static_valid(asym: SimpleNamespace) -> None:
exp = datetime.now(UTC) + timedelta(hours=1) exp = datetime.now(UTC) + timedelta(hours=1)
token = jwt.encode(_payload(exp=exp), asym.priv, algorithm=asym.alg) token = jwt.encode(_payload(exp=exp), asym.priv, algorithm=asym.alg)
@ -360,15 +400,27 @@ def test_jwks_rejects_both_sources(rsa_key: rsa.RSAPrivateKey) -> None:
# --- JwksVerifier (JWKS: заглушка клиента и реальный PyJWKClient через локальный HTTP-сервер) -------------------- # --- JwksVerifier (JWKS: заглушка клиента и реальный PyJWKClient через локальный HTTP-сервер) --------------------
class _FakeJwkClient: def _jwk(public_key: Any, kid: str) -> dict[str, Any]:
def __init__(self, key: Any = None, error: Exception | None = None) -> None: """JWK (словарь) публичного ключа RSA или EC P-256."""
self._key, self._error, self.calls = key, error, 0 algorithm = jwt.algorithms.RSAAlgorithm if isinstance(public_key, rsa.RSAPublicKey) else jwt.algorithms.ECAlgorithm
jwk = json.loads(algorithm.to_jwk(public_key))
jwk.update(kid=kid, use="sig")
return jwk
def get_signing_key_from_jwt(self, token: str) -> Any:
class _FakeJwkClient:
"""Заглушка PyJWKClient: отдаёт PyJWK (как настоящий клиент), без сети."""
def __init__(self, key: Any = None, error: Exception | None = None) -> None:
self._jwk = jwt.PyJWK(_jwk(key, "k1")) if key is not None else None
self._error, self.calls = error, 0
def get_signing_key_from_jwt(self, token: str) -> jwt.PyJWK:
self.calls += 1 self.calls += 1
if self._error is not None: if self._error is not None:
raise self._error raise self._error
return SimpleNamespace(key=self._key) assert self._jwk is not None
return self._jwk
async def test_jwks_client_valid(rsa_key: rsa.RSAPrivateKey) -> None: async def test_jwks_client_valid(rsa_key: rsa.RSAPrivateKey) -> None:
@ -409,38 +461,114 @@ async def test_jwks_client_failure_is_auth_error_without_internals(log_messages:
assert any("internal-host" in m for m in log_messages) # деталь — только в лог assert any("internal-host" in m for m in log_messages) # деталь — только в лог
class _JwksHandler(BaseHTTPRequestHandler): class _JwksServer:
body = b"" """Локальный JWKS-эндпоинт (только loopback): набор ключей можно менять между запросами."""
def do_GET(self) -> None: def __init__(self, keys: list[dict[str, Any]]) -> None:
self.send_response(200) self.keys = keys
self.send_header("Content-Type", "application/json") outer = self
self.end_headers()
self.wfile.write(self.body)
def log_message(self, format: str, *args: Any) -> None: # noqa: A002 - сигнатура базового класса class Handler(BaseHTTPRequestHandler):
return def do_GET(self) -> None:
self.send_response(200)
self.send_header("Content-Type", "application/json")
self.end_headers()
self.wfile.write(json.dumps({"keys": outer.keys}).encode())
def log_message(self, format: str, *args: Any) -> None: # noqa: A002 - сигнатура базового класса
return
self._server = HTTPServer(("127.0.0.1", 0), Handler)
self.url = f"http://127.0.0.1:{self._server.server_port}/jwks"
threading.Thread(target=self._server.serve_forever, daemon=True).start()
def close(self) -> None:
self._server.shutdown()
self._server.server_close()
async def test_jwks_real_pyjwkclient_over_loopback(rsa_key: rsa.RSAPrivateKey) -> None: @pytest.fixture
def jwks_server_factory() -> Iterator[Callable[[list[dict[str, Any]]], _JwksServer]]:
servers: list[_JwksServer] = []
def make(keys: list[dict[str, Any]]) -> _JwksServer:
servers.append(_JwksServer(keys))
return servers[-1]
yield make
for server in servers:
server.close()
def _forge(header: dict[str, Any], payload: dict[str, Any]) -> str:
"""Токен с произвольным заголовком и фиктивной подписью (проверка алгоритма идёт раньше подписи)."""
body = json.dumps(payload, default=lambda v: int(v.timestamp())).encode()
return (_b64(json.dumps(header).encode()) + b"." + _b64(body) + b"." + _b64(b"x" * 64)).decode()
async def test_jwks_real_pyjwkclient_over_loopback(
rsa_key: rsa.RSAPrivateKey, jwks_server_factory: Callable[[list[dict[str, Any]]], _JwksServer]
) -> None:
"""Реальный PyJWKClient: выбор ключа по kid из JWKS, отдаваемого локальным сервером (только loopback).""" """Реальный PyJWKClient: выбор ключа по kid из JWKS, отдаваемого локальным сервером (только loopback)."""
jwk = json.loads(jwt.algorithms.RSAAlgorithm.to_jwk(rsa_key.public_key())) server = jwks_server_factory([_jwk(rsa_key.public_key(), "key-1")])
jwk.update(kid="key-1", use="sig", alg="RS256") verifier = JwksVerifier(jwks_url=server.url)
_JwksHandler.body = json.dumps({"keys": [jwk]}).encode() good = jwt.encode(_payload(), _pem_private(rsa_key), algorithm="RS256", headers={"kid": "key-1"})
server = HTTPServer(("127.0.0.1", 0), _JwksHandler) assert (await verifier.verify(good)).email == EMAIL
thread = threading.Thread(target=server.serve_forever, daemon=True) unknown_kid = jwt.encode(_payload(), _pem_private(rsa_key), algorithm="RS256", headers={"kid": "nope"})
thread.start() with pytest.raises(AuthError, match="ключ"):
try: await verifier.verify(unknown_kid)
url = f"http://127.0.0.1:{server.server_port}/jwks"
verifier = JwksVerifier(jwks_url=url)
good = jwt.encode(_payload(), _pem_private(rsa_key), algorithm="RS256", headers={"kid": "key-1"}) async def test_jwks_cross_typed_alg_and_kid_is_401_not_500(
assert (await verifier.verify(good)).email == EMAIL rsa_key: rsa.RSAPrivateKey,
unknown_kid = jwt.encode(_payload(), _pem_private(rsa_key), algorithm="RS256", headers={"kid": "nope"}) ec_key: ec.EllipticCurvePrivateKey,
with pytest.raises(AuthError, match="ключ"): jwks_server_factory: Callable[[list[dict[str, Any]]], _JwksServer],
await verifier.verify(unknown_kid) ) -> None:
finally: """JWKS с RSA- и EC-ключом: алгоритм из заголовка не совпадает с типом ключа -> AuthError, а не TypeError."""
server.shutdown() server = jwks_server_factory([_jwk(rsa_key.public_key(), "r1"), _jwk(ec_key.public_key(), "e1")])
server.server_close() verifier = JwksVerifier(jwks_url=server.url)
# штатные токены обоих типов проходят
assert (await verifier.verify(jwt.encode(_payload(), _pem_private(rsa_key), "RS256", headers={"kid": "r1"}))).email
assert (await verifier.verify(jwt.encode(_payload(), _pem_private(ec_key), "ES256", headers={"kid": "e1"}))).email
for alg, kid in (("ES256", "r1"), ("RS256", "e1")):
with pytest.raises(AuthError):
await verifier.verify(_forge({"alg": alg, "kid": kid, "typ": "JWT"}, _payload()))
async def test_jwks_revoked_key_is_rejected_after_set_cache_expiry(
rsa_key: rsa.RSAPrivateKey,
other_rsa_key: rsa.RSAPrivateKey,
jwks_server_factory: Callable[[list[dict[str, Any]]], _JwksServer],
) -> None:
"""Ключ, исчезнувший из JWKS, перестаёт приниматься после истечения кэша набора (кэша отдельных ключей нет)."""
keep = _jwk(other_rsa_key.public_key(), "r2")
server = jwks_server_factory([_jwk(rsa_key.public_key(), "r1"), keep])
verifier = JwksVerifier(jwks_url=server.url)
client = verifier._client
assert isinstance(client, PyJWKClient)
assert client.jwk_set_cache is not None
assert client.jwk_set_cache.lifespan == JWKS_CACHE_LIFESPAN_SECONDS
assert JWKS_CACHE_LIFESPAN_SECONDS <= SET_CACHE_MAX_SECONDS
token = jwt.encode(_payload(), _pem_private(rsa_key), "RS256", headers={"kid": "r1"})
assert (await verifier.verify(token)).email == EMAIL
server.keys = [keep] # эмитент отозвал r1
client.jwk_set_cache.lifespan = 0.05 # ускоренное «истечение» кэша набора
time.sleep(0.1)
with pytest.raises(AuthError, match="ключ"):
await verifier.verify(token)
async def test_jwks_kid_is_logged_escaped_and_truncated(log_messages: list[str]) -> None:
"""kid приходит от клиента: в логе он экранирован (нет переводов строк) и усечён."""
hostile = "x\nINJECTED " + "A" * 5000
verifier = JwksVerifier(
jwk_client=_FakeJwkClient(error=PyJWKClientError(f'Unable to find a signing key that matches: "{hostile}"'))
)
with pytest.raises(AuthError):
await verifier.verify(jwt.encode(_payload(), DEV_SECRET, algorithm="HS256"))
(message,) = log_messages
assert "\n" not in message
assert len(message) < LOG_LINE_MAX
# --- Фабрика -------------------------------------------------------------------------------------------------- # --- Фабрика --------------------------------------------------------------------------------------------------