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:
parent
d7469f31bc
commit
5ed52a7239
@ -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)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@ -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
|
||||||
|
|
||||||
|
|
||||||
# --- Фабрика --------------------------------------------------------------------------------------------------
|
# --- Фабрика --------------------------------------------------------------------------------------------------
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user