feat: ядро аутентификации (Claims, TokenVerifier, DevHs256Verifier, JwksVerifier, build_verifier)

Dev HS256 включается только при AUTH_DEV_ISSUER и DEBUG одновременно; РАИСА — RS256/ES256 по
публичному ключу или JWKS с кэшем. PyJWT[crypto] добавлен в requirements.txt как прод-зависимость.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
Andrey Epifancev 2026-10-03 21:51:51 +04:00
parent 7429240f01
commit 70b47a097d
3 changed files with 805 additions and 0 deletions

View File

@ -44,6 +44,7 @@ pydantic-extra-types==2.11.0
pydantic-settings==2.14.2
pydantic_core==2.46.4
Pygments==2.20.0
PyJWT[crypto]==2.15.1
PyNaCl==1.5.0
python-dotenv==1.2.2
python-multipart==0.0.32

250
api/src/core/auth.py Normal file
View File

@ -0,0 +1,250 @@
"""Аутентификация: проверка JWT и фабрика проверяющего по настройкам (D-5).
Модуль ничего не знает про HTTP и БД: он превращает строку токена в :class:`Claims` или бросает :class:`AuthError`
(сообщение по-русски, пригодно для ``{"detail": ...}`` ответа 401). Поиск ``app_user`` по email и права — зависимости
``src/api/dependencies/auth.py`` (задача 17).
Проверяющие:
* :class:`DevHs256Verifier` — только для разработки: HS256 с общим секретом ``AUTH_DEV_SECRET``; выпускает токены для
``POST /api/v1/dev/token``. Создаётся фабрикой при ``AUTH_DEV_ISSUER=true`` **и** ``DEBUG=true``.
* :class:`JwksVerifier` — каркас под токены РАИСА (параметры JWT РАИСА неизвестны, решение #5 design.md): подпись
RS256/ES256 по публичному ключу ``AUTH_PUBLIC_KEY`` (PEM) либо по ключам из ``AUTH_JWKS_URL`` (с кэшем).
Фабрика :func:`build_verifier` возвращает ``None``, если ни dev-режим, ни ключи РАИСА не настроены: в этом случае
токены не принимаются вовсе (зависимость ``current_user`` отвечает 401 «аутентификация не настроена»).
"""
from __future__ import annotations
import asyncio
from dataclasses import dataclass
from datetime import UTC, datetime, timedelta
from typing import TYPE_CHECKING, Any, Protocol
import jwt
from cryptography.hazmat.primitives import serialization
from cryptography.hazmat.primitives.asymmetric import ec, rsa
from jwt import PyJWKClient
from loguru import logger
if TYPE_CHECKING:
from src.core.config import Settings
# Алгоритмы РАИСА-проверяющего: асимметричные, явно. ``none`` и HS* не допускаются никогда (атака подмены алгоритма).
JWKS_ALGORITHMS: tuple[str, ...] = ("RS256", "ES256")
DEV_ALGORITHM = "HS256"
# Допуск на расхождение часов с эмитентом токена
JWKS_LEEWAY_SECONDS = 10
# Срок жизни dev-токена (требование спеки authn-authz)
DEV_TOKEN_TTL = timedelta(hours=8)
# Время жизни кэша набора ключей JWKS (PyJWKClient), секунды
JWKS_CACHE_LIFESPAN_SECONDS = 300
JWKS_FETCH_TIMEOUT_SECONDS = 10
class AuthError(Exception):
"""Токен не принят. Текст сообщения — по-русски, без внутренностей; годится для тела 401."""
class TokenExpiredError(AuthError):
"""Срок действия токена истёк."""
class TokenSignatureError(AuthError):
"""Подпись токена не проходит проверку."""
@dataclass(frozen=True)
class Claims:
"""Проверенные данные токена: ``sub``, email пользователя и момент окончания срока (UTC)."""
subject: str
email: str
exp: datetime
class TokenVerifier(Protocol):
"""Проверяющий токены. ``verify`` асинхронный: JWKS-проверяющий может ходить за ключами по сети."""
async def verify(self, token: str) -> Claims:
"""Возвращает claims проверенного токена или бросает :class:`AuthError`."""
...
class _SigningKeyProvider(Protocol):
"""Источник ключа проверки подписи по токену (интерфейс :class:`jwt.PyJWKClient`)."""
def get_signing_key_from_jwt(self, token: str) -> Any: ...
# Порядок важен: более конкретные классы исключений PyJWT — раньше общих
_ERROR_MESSAGES: tuple[tuple[type[jwt.PyJWTError], type[AuthError], str], ...] = (
(jwt.ExpiredSignatureError, TokenExpiredError, "срок действия токена истёк"),
(jwt.InvalidSignatureError, TokenSignatureError, "недействительная подпись токена"),
(jwt.InvalidAlgorithmError, AuthError, "недопустимый алгоритм подписи токена"),
(jwt.InvalidIssuerError, AuthError, "токен выпущен неизвестным эмитентом"),
(jwt.InvalidAudienceError, AuthError, "токен предназначен для другого сервиса"),
(jwt.ImmatureSignatureError, AuthError, "токен ещё не действителен"),
(jwt.DecodeError, AuthError, "некорректный токен"),
)
def _to_auth_error(exc: jwt.PyJWTError) -> AuthError:
"""Переводит ошибку PyJWT в :class:`AuthError` с русским сообщением; детали библиотеки наружу не отдаются."""
for exc_type, auth_type, message in _ERROR_MESSAGES:
if isinstance(exc, exc_type):
return auth_type(message)
if isinstance(exc, jwt.MissingRequiredClaimError):
return AuthError(f"в токене нет обязательного поля «{exc.claim}»")
if isinstance(exc, jwt.PyJWKClientError):
# сбой получения ключей (сеть, неизвестный kid) — деталь только в лог, не в ответ
logger.warning("Не удалось получить ключ проверки подписи JWT: {}", exc)
return AuthError("не удалось получить ключ проверки подписи токена")
return AuthError("недействительный токен")
def _claims_from_payload(payload: dict[str, Any], email_claim: str) -> Claims:
"""Собирает :class:`Claims` из проверенной полезной нагрузки; ``exp`` и ``sub`` уже проверены на наличие."""
email = payload.get(email_claim)
if not isinstance(email, str) or not email.strip():
raise AuthError("в токене нет email")
return Claims(
subject=str(payload["sub"]),
email=email.strip(),
exp=datetime.fromtimestamp(payload["exp"], tz=UTC),
)
class DevHs256Verifier:
"""Проверяющий и эмитент dev-токенов: HS256 с общим секретом. Только для разработки (``DEBUG=true``)."""
def __init__(self, secret: str, *, email_claim: str = "email") -> None:
if not secret:
raise ValueError("секрет dev-эмитента не задан")
self._secret = secret
self._email_claim = email_claim
async def verify(self, token: str) -> Claims:
try:
payload = jwt.decode(
token,
self._secret,
algorithms=[DEV_ALGORITHM],
options={"require": ["exp", "sub"], "verify_aud": False},
)
except jwt.PyJWTError as exc:
raise _to_auth_error(exc) from exc
return _claims_from_payload(payload, self._email_claim)
def issue_token(self, email: str, *, ttl: timedelta = DEV_TOKEN_TTL) -> str:
"""Выпускает HS256 JWT для ``POST /api/v1/dev/token``: ``sub`` = email, email в настроенном claim."""
now = datetime.now(UTC)
payload = {"sub": email, self._email_claim: email, "iat": now, "exp": now + ttl}
return jwt.encode(payload, self._secret, algorithm=DEV_ALGORITHM)
def _load_static_key(pem: str) -> tuple[Any, tuple[str, ...]]:
"""Загружает публичный ключ из PEM и выводит допустимый алгоритм из его типа (RSA → RS256, EC P-256 → ES256)."""
# в .env PEM удобно хранить одной строкой с литеральными «\n»
try:
key = serialization.load_pem_public_key(pem.replace("\\n", "\n").strip().encode())
except (ValueError, TypeError) as exc:
raise ValueError("AUTH_PUBLIC_KEY не является публичным ключом в формате PEM") from exc
if isinstance(key, rsa.RSAPublicKey):
return key, ("RS256",)
if isinstance(key, ec.EllipticCurvePublicKey) and key.curve.name == "secp256r1":
return key, ("ES256",)
raise ValueError("AUTH_PUBLIC_KEY: поддерживаются ключи RSA и EC P-256 (алгоритмы RS256 и ES256)")
class JwksVerifier:
"""Проверяющий токены РАИСА: RS256/ES256 по статическому публичному ключу либо по JWKS (кэш ключей).
``iss`` проверяется, только если задан ``issuer``; ``aud`` — только если задан ``audience``. ``exp`` и ``sub``
обязательны всегда. ``jwk_client`` — внедряемый источник ключей (тесты); по умолчанию :class:`jwt.PyJWKClient`.
"""
def __init__( # noqa: PLR0913 - все параметры именованные и соответствуют настройкам AUTH_*
self,
*,
jwks_url: str = "",
public_key: str = "",
issuer: str = "",
audience: str = "",
email_claim: str = "email",
leeway: int = JWKS_LEEWAY_SECONDS,
jwk_client: _SigningKeyProvider | None = None,
) -> None:
sources = [bool(jwks_url), bool(public_key), jwk_client is not None]
if sum(sources) == 0:
raise ValueError("не задан ключ проверки подписи: нужен AUTH_JWKS_URL или AUTH_PUBLIC_KEY")
if sum(sources) > 1:
raise ValueError("AUTH_JWKS_URL и AUTH_PUBLIC_KEY заданы одновременно: оставьте один источник ключа")
self._static_key: Any = None
self._static_algorithms: tuple[str, ...] = ()
self._client: _SigningKeyProvider | None = jwk_client
if public_key:
self._static_key, self._static_algorithms = _load_static_key(public_key)
elif jwks_url:
self._client = PyJWKClient(
jwks_url,
cache_keys=True,
cache_jwk_set=True,
lifespan=JWKS_CACHE_LIFESPAN_SECONDS,
timeout=JWKS_FETCH_TIMEOUT_SECONDS,
)
self._issuer = issuer or None
self._audience = audience or None
self._email_claim = email_claim
self._leeway = leeway
async def _signing_key(self, token: str) -> tuple[Any, list[str]]:
if self._client is None:
return self._static_key, list(self._static_algorithms)
# PyJWKClient ходит в сеть блокирующе — выносим из цикла событий
signing_key = await asyncio.to_thread(self._client.get_signing_key_from_jwt, token)
return signing_key.key, list(JWKS_ALGORITHMS)
async def verify(self, token: str) -> Claims:
try:
key, algorithms = await self._signing_key(token)
payload = jwt.decode(
token,
key,
algorithms=algorithms,
issuer=self._issuer,
audience=self._audience,
leeway=self._leeway,
options={"require": ["exp", "sub"], "verify_aud": self._audience is not None},
)
except jwt.PyJWTError as exc:
raise _to_auth_error(exc) from exc
return _claims_from_payload(payload, self._email_claim)
def build_verifier(settings: Settings) -> TokenVerifier | None:
"""Собирает проверяющего токены по настройкам.
* ``AUTH_DEV_ISSUER`` **и** ``DEBUG`` → :class:`DevHs256Verifier` (только он; JWKS в этом режиме не используется);
пустой ``AUTH_DEV_SECRET`` — ошибка конфигурации (``RuntimeError``).
* иначе, если задан ``AUTH_JWKS_URL`` или ``AUTH_PUBLIC_KEY`` → :class:`JwksVerifier`.
* иначе ``None``: проверять нечем, токены не принимаются. ``AUTH_DEV_ISSUER`` без ``DEBUG`` dev-проверяющего
не включает никогда, даже при заданном секрете.
"""
if settings.AUTH_DEV_ISSUER and settings.DEBUG:
if not settings.AUTH_DEV_SECRET:
raise RuntimeError("AUTH_DEV_ISSUER=true и DEBUG=true, но AUTH_DEV_SECRET не задан")
logger.warning("Включён dev-эмитент токенов (AUTH_DEV_ISSUER и DEBUG): только для разработки")
return DevHs256Verifier(settings.AUTH_DEV_SECRET, email_claim=settings.AUTH_EMAIL_CLAIM)
if settings.AUTH_DEV_ISSUER:
logger.warning("AUTH_DEV_ISSUER=true проигнорирован: dev-эмитент работает только при DEBUG=true")
if settings.AUTH_JWKS_URL or settings.AUTH_PUBLIC_KEY:
return JwksVerifier(
jwks_url=settings.AUTH_JWKS_URL,
public_key=settings.AUTH_PUBLIC_KEY,
issuer=settings.AUTH_ISSUER,
audience=settings.AUTH_AUDIENCE,
email_claim=settings.AUTH_EMAIL_CLAIM,
)
return None

554
api/tests/test_auth_core.py Normal file
View File

@ -0,0 +1,554 @@
"""Ядро аутентификации: проверяющие токены и фабрика по настройкам (без БД, без внешней сети)."""
import base64
import hashlib
import hmac
import json
import os
import threading
from datetime import UTC, datetime, timedelta
from http.server import BaseHTTPRequestHandler, HTTPServer
from types import SimpleNamespace
from typing import Any
import jwt
import pytest
from cryptography.hazmat.primitives import serialization
from cryptography.hazmat.primitives.asymmetric import ec, rsa
from jwt.exceptions import PyJWKClientConnectionError
from src.core.auth import (
AuthError,
Claims,
DevHs256Verifier,
JwksVerifier,
TokenExpiredError,
TokenSignatureError,
build_verifier,
)
from src.core.config import Settings
DEV_SECRET = "dev-secret-for-unit-tests-0123456789abcdef"
OTHER_SECRET = "another-secret-for-unit-tests-0123456789"
EMAIL = "ka_rf@armka.local"
class _LogSpy:
"""Подмена logger модуля auth: собирает сообщения, не пишет в stderr."""
def __init__(self) -> None:
self.messages: list[str] = []
def warning(self, message: str, *args: Any) -> None:
self.messages.append(message.format(*args))
@pytest.fixture(autouse=True)
def log_messages(monkeypatch: pytest.MonkeyPatch) -> list[str]:
"""Перехватывает логи модуля auth (шум не попадает в вывод тестов) и отдаёт их для проверок."""
spy = _LogSpy()
monkeypatch.setattr("src.core.auth.logger", spy)
return spy.messages
def _payload(**overrides: Any) -> dict[str, Any]:
now = datetime.now(UTC)
data: dict[str, Any] = {"sub": EMAIL, "email": EMAIL, "iat": now, "exp": now + timedelta(hours=1)}
data.update(overrides)
return {k: v for k, v in data.items() if v is not None}
def _pem_public(key: Any) -> str:
return (
key.public_key()
.public_bytes(serialization.Encoding.PEM, serialization.PublicFormat.SubjectPublicKeyInfo)
.decode()
)
def _pem_private(key: Any) -> str:
return key.private_bytes(
serialization.Encoding.PEM, serialization.PrivateFormat.PKCS8, serialization.NoEncryption()
).decode()
def _b64(data: bytes) -> bytes:
return base64.urlsafe_b64encode(data).rstrip(b"=")
def _forge_hs(payload: dict[str, Any], secret: bytes, alg: str = "HS256") -> str:
"""Собирает HS-токен вручную (PyJWT не даст подписать HMAC асимметричным PEM — атаки подмены ключа)."""
header = _b64(json.dumps({"alg": alg, "typ": "JWT"}).encode())
body = _b64(json.dumps(payload, default=lambda v: int(v.timestamp())).encode())
digest = {"HS256": hashlib.sha256, "HS384": hashlib.sha384}[alg]
sig = _b64(hmac.new(secret, header + b"." + body, digest).digest())
return (header + b"." + body + b"." + sig).decode()
def _unsigned(payload: dict[str, Any]) -> str:
header = _b64(json.dumps({"alg": "none", "typ": "JWT"}).encode())
body = _b64(json.dumps(payload, default=lambda v: int(v.timestamp())).encode())
return (header + b"." + body + b".").decode()
@pytest.fixture(scope="module")
def rsa_key() -> rsa.RSAPrivateKey:
return rsa.generate_private_key(public_exponent=65537, key_size=2048)
@pytest.fixture(scope="module")
def other_rsa_key() -> rsa.RSAPrivateKey:
return rsa.generate_private_key(public_exponent=65537, key_size=2048)
@pytest.fixture(scope="module")
def ec_key() -> ec.EllipticCurvePrivateKey:
return ec.generate_private_key(ec.SECP256R1())
@pytest.fixture(scope="module")
def other_ec_key() -> ec.EllipticCurvePrivateKey:
return ec.generate_private_key(ec.SECP256R1())
# --- DevHs256Verifier -------------------------------------------------------------------------------------------
@pytest.fixture
def dev() -> DevHs256Verifier:
return DevHs256Verifier(DEV_SECRET)
async def test_dev_valid_token(dev: DevHs256Verifier) -> None:
exp = datetime.now(UTC) + timedelta(hours=2)
token = jwt.encode(_payload(exp=exp), DEV_SECRET, algorithm="HS256")
claims = await dev.verify(token)
assert isinstance(claims, Claims)
assert claims.subject == EMAIL
assert claims.email == EMAIL
assert claims.exp == exp.replace(microsecond=0)
assert claims.exp.tzinfo is not None
async def test_dev_expired(dev: DevHs256Verifier) -> None:
token = jwt.encode(_payload(exp=datetime.now(UTC) - timedelta(minutes=5)), DEV_SECRET, algorithm="HS256")
with pytest.raises(TokenExpiredError, match="срок действия"):
await dev.verify(token)
async def test_dev_foreign_secret(dev: DevHs256Verifier) -> None:
token = jwt.encode(_payload(), OTHER_SECRET, algorithm="HS256")
with pytest.raises(TokenSignatureError, match="подпись"):
await dev.verify(token)
async def test_dev_no_email(dev: DevHs256Verifier) -> None:
token = jwt.encode(_payload(email=None), DEV_SECRET, algorithm="HS256")
with pytest.raises(AuthError, match="нет email"):
await dev.verify(token)
async def test_dev_empty_email(dev: DevHs256Verifier) -> None:
token = jwt.encode(_payload(email=" "), DEV_SECRET, algorithm="HS256")
with pytest.raises(AuthError, match="нет email"):
await dev.verify(token)
async def test_dev_no_exp(dev: DevHs256Verifier) -> None:
token = jwt.encode(_payload(exp=None), DEV_SECRET, algorithm="HS256")
with pytest.raises(AuthError, match="exp"):
await dev.verify(token)
async def test_dev_no_sub(dev: DevHs256Verifier) -> None:
token = jwt.encode(_payload(sub=None), DEV_SECRET, algorithm="HS256")
with pytest.raises(AuthError, match="sub"):
await dev.verify(token)
async def test_dev_alg_none(dev: DevHs256Verifier) -> None:
with pytest.raises(AuthError):
await dev.verify(_unsigned(_payload()))
async def test_dev_alg_hs384(dev: DevHs256Verifier) -> None:
"""Токен с корректной подписью, но другим алгоритмом (HS384) не принимается: алгоритм зафиксирован."""
with pytest.raises(AuthError, match="алгоритм"):
await dev.verify(_forge_hs(_payload(), DEV_SECRET.encode(), alg="HS384"))
@pytest.mark.parametrize("token", ["", "abc", "a.b.c", "не-токен"])
async def test_dev_garbage(dev: DevHs256Verifier, token: str) -> None:
with pytest.raises(AuthError):
await dev.verify(token)
async def test_dev_issue_token_roundtrip(dev: DevHs256Verifier) -> None:
before = datetime.now(UTC)
token = dev.issue_token(EMAIL)
claims = await dev.verify(token)
assert claims.subject == EMAIL
assert claims.email == EMAIL
assert timedelta(hours=8) - timedelta(seconds=5) <= claims.exp - before <= timedelta(hours=8, seconds=5)
header = jwt.get_unverified_header(token)
assert header["alg"] == "HS256"
assert "iat" in jwt.decode(token, options={"verify_signature": False})
async def test_dev_issue_token_custom_ttl_and_claim() -> None:
dev = DevHs256Verifier(DEV_SECRET, email_claim="mail")
token = dev.issue_token(EMAIL, ttl=timedelta(minutes=10))
assert jwt.decode(token, options={"verify_signature": False})["mail"] == EMAIL
claims = await dev.verify(token)
assert claims.email == EMAIL
assert claims.exp - datetime.now(UTC) < timedelta(minutes=11)
def test_dev_empty_secret_forbidden() -> None:
with pytest.raises(ValueError, match="секрет"):
DevHs256Verifier("")
# --- JwksVerifier (статический публичный ключ) -------------------------------------------------------------------
@pytest.fixture(params=["rs256", "es256"])
def asym(
request: pytest.FixtureRequest,
rsa_key: rsa.RSAPrivateKey,
other_rsa_key: rsa.RSAPrivateKey,
ec_key: ec.EllipticCurvePrivateKey,
other_ec_key: ec.EllipticCurvePrivateKey,
) -> SimpleNamespace:
"""Пара алгоритм/ключи: подписывающий, публичный PEM для проверяющего и чужой подписывающий ключ."""
if request.param == "rs256":
return SimpleNamespace(
alg="RS256", priv=_pem_private(rsa_key), pub=_pem_public(rsa_key), other=_pem_private(other_rsa_key)
)
return SimpleNamespace(
alg="ES256", priv=_pem_private(ec_key), pub=_pem_public(ec_key), other=_pem_private(other_ec_key)
)
async def test_jwks_static_valid(asym: SimpleNamespace) -> None:
exp = datetime.now(UTC) + timedelta(hours=1)
token = jwt.encode(_payload(exp=exp), asym.priv, algorithm=asym.alg)
claims = await JwksVerifier(public_key=asym.pub).verify(token)
assert (claims.subject, claims.email) == (EMAIL, EMAIL)
assert claims.exp == exp.replace(microsecond=0)
async def test_jwks_static_pem_with_escaped_newlines(asym: SimpleNamespace) -> None:
"""В .env PEM удобно хранить одной строкой с литеральными \\n."""
token = jwt.encode(_payload(), asym.priv, algorithm=asym.alg)
claims = await JwksVerifier(public_key=asym.pub.replace("\n", "\\n")).verify(token)
assert claims.email == EMAIL
async def test_jwks_expired(asym: SimpleNamespace) -> None:
token = jwt.encode(_payload(exp=datetime.now(UTC) - timedelta(minutes=5)), asym.priv, algorithm=asym.alg)
with pytest.raises(TokenExpiredError):
await JwksVerifier(public_key=asym.pub).verify(token)
async def test_jwks_leeway_tolerates_small_clock_skew(asym: SimpleNamespace) -> None:
token = jwt.encode(_payload(exp=datetime.now(UTC) - timedelta(seconds=3)), asym.priv, algorithm=asym.alg)
claims = await JwksVerifier(public_key=asym.pub).verify(token)
assert claims.email == EMAIL
async def test_jwks_foreign_key(asym: SimpleNamespace) -> None:
token = jwt.encode(_payload(), asym.other, algorithm=asym.alg)
with pytest.raises(TokenSignatureError):
await JwksVerifier(public_key=asym.pub).verify(token)
async def test_jwks_no_exp(asym: SimpleNamespace) -> None:
token = jwt.encode(_payload(exp=None), asym.priv, algorithm=asym.alg)
with pytest.raises(AuthError, match="exp"):
await JwksVerifier(public_key=asym.pub).verify(token)
async def test_jwks_no_sub(asym: SimpleNamespace) -> None:
token = jwt.encode(_payload(sub=None), asym.priv, algorithm=asym.alg)
with pytest.raises(AuthError, match="sub"):
await JwksVerifier(public_key=asym.pub).verify(token)
async def test_jwks_issuer_checked_when_configured(asym: SimpleNamespace) -> None:
verifier = JwksVerifier(public_key=asym.pub, issuer="https://sso.raisa")
ok = jwt.encode(_payload(iss="https://sso.raisa"), asym.priv, algorithm=asym.alg)
assert (await verifier.verify(ok)).email == EMAIL
for bad in (_payload(iss="https://evil"), _payload()):
with pytest.raises(AuthError):
await verifier.verify(jwt.encode(bad, asym.priv, algorithm=asym.alg))
async def test_jwks_issuer_ignored_when_not_configured(asym: SimpleNamespace) -> None:
token = jwt.encode(_payload(iss="whatever"), asym.priv, algorithm=asym.alg)
assert (await JwksVerifier(public_key=asym.pub).verify(token)).email == EMAIL
async def test_jwks_audience_checked_when_configured(asym: SimpleNamespace) -> None:
verifier = JwksVerifier(public_key=asym.pub, audience="arm-core")
ok = jwt.encode(_payload(aud="arm-core"), asym.priv, algorithm=asym.alg)
assert (await verifier.verify(ok)).email == EMAIL
for bad in (_payload(aud="other"), _payload()):
with pytest.raises(AuthError):
await verifier.verify(jwt.encode(bad, asym.priv, algorithm=asym.alg))
async def test_jwks_audience_in_token_ignored_when_not_configured(asym: SimpleNamespace) -> None:
token = jwt.encode(_payload(aud="anything"), asym.priv, algorithm=asym.alg)
assert (await JwksVerifier(public_key=asym.pub).verify(token)).email == EMAIL
async def test_jwks_custom_email_claim(asym: SimpleNamespace) -> None:
verifier = JwksVerifier(public_key=asym.pub, email_claim="preferred_username")
token = jwt.encode(_payload(preferred_username="ivan@rshb.ru"), asym.priv, algorithm=asym.alg)
assert (await verifier.verify(token)).email == "ivan@rshb.ru"
# claim «email» при настроенном другом имени не подходит
with pytest.raises(AuthError, match="нет email"):
await verifier.verify(jwt.encode(_payload(), asym.priv, algorithm=asym.alg))
async def test_jwks_alg_none(asym: SimpleNamespace) -> None:
with pytest.raises(AuthError):
await JwksVerifier(public_key=asym.pub).verify(_unsigned(_payload()))
async def test_jwks_key_confusion_hs256_signed_with_public_key(asym: SimpleNamespace) -> None:
"""Атака подмены алгоритма: HS256, подписанный байтами публичного ключа как секретом, не принимается."""
for secret in (asym.pub.encode(), asym.pub.strip().encode()):
with pytest.raises(AuthError):
await JwksVerifier(public_key=asym.pub).verify(_forge_hs(_payload(), secret))
async def test_jwks_rejects_dev_token(asym: SimpleNamespace) -> None:
token = jwt.encode(_payload(), DEV_SECRET, algorithm="HS256")
with pytest.raises(AuthError):
await JwksVerifier(public_key=asym.pub).verify(token)
async def test_jwks_rs256_key_does_not_verify_es256_token(
rsa_key: rsa.RSAPrivateKey, ec_key: ec.EllipticCurvePrivateKey
) -> None:
"""Алгоритм выводится из типа ключа: RSA-ключ принимает только RS256, EC-ключ — только ES256."""
es_token = jwt.encode(_payload(), _pem_private(ec_key), algorithm="ES256")
rs_token = jwt.encode(_payload(), _pem_private(rsa_key), algorithm="RS256")
with pytest.raises(AuthError):
await JwksVerifier(public_key=_pem_public(rsa_key)).verify(es_token)
with pytest.raises(AuthError):
await JwksVerifier(public_key=_pem_public(ec_key)).verify(rs_token)
def test_jwks_requires_a_key_source() -> None:
with pytest.raises(ValueError, match="AUTH_JWKS_URL|ключ"):
JwksVerifier()
def test_jwks_bad_pem() -> None:
with pytest.raises(ValueError, match="PEM"):
JwksVerifier(public_key="not a pem")
def test_jwks_rejects_both_sources(rsa_key: rsa.RSAPrivateKey) -> None:
with pytest.raises(ValueError, match="одновременно"):
JwksVerifier(public_key=_pem_public(rsa_key), jwks_url="http://127.0.0.1:1/jwks")
# --- JwksVerifier (JWKS: заглушка клиента и реальный PyJWKClient через локальный HTTP-сервер) --------------------
class _FakeJwkClient:
def __init__(self, key: Any = None, error: Exception | None = None) -> None:
self._key, self._error, self.calls = key, error, 0
def get_signing_key_from_jwt(self, token: str) -> Any:
self.calls += 1
if self._error is not None:
raise self._error
return SimpleNamespace(key=self._key)
async def test_jwks_client_valid(rsa_key: rsa.RSAPrivateKey) -> None:
client = _FakeJwkClient(key=rsa_key.public_key())
verifier = JwksVerifier(jwk_client=client)
token = jwt.encode(_payload(), _pem_private(rsa_key), algorithm="RS256")
assert (await verifier.verify(token)).email == EMAIL
assert client.calls == 1
async def test_jwks_client_foreign_key(rsa_key: rsa.RSAPrivateKey, other_rsa_key: rsa.RSAPrivateKey) -> None:
verifier = JwksVerifier(jwk_client=_FakeJwkClient(key=rsa_key.public_key()))
token = jwt.encode(_payload(), _pem_private(other_rsa_key), algorithm="RS256")
with pytest.raises(TokenSignatureError):
await verifier.verify(token)
async def test_jwks_client_es256(ec_key: ec.EllipticCurvePrivateKey) -> None:
verifier = JwksVerifier(jwk_client=_FakeJwkClient(key=ec_key.public_key()))
token = jwt.encode(_payload(), _pem_private(ec_key), algorithm="ES256")
assert (await verifier.verify(token)).email == EMAIL
async def test_jwks_client_key_confusion(rsa_key: rsa.RSAPrivateKey) -> None:
verifier = JwksVerifier(jwk_client=_FakeJwkClient(key=rsa_key.public_key()))
with pytest.raises(AuthError):
await verifier.verify(_forge_hs(_payload(), _pem_public(rsa_key).encode()))
async def test_jwks_client_failure_is_auth_error_without_internals(log_messages: list[str]) -> None:
secret_detail = "connection to http://internal-host:8443/keys refused"
verifier = JwksVerifier(jwk_client=_FakeJwkClient(error=PyJWKClientConnectionError(secret_detail)))
token = jwt.encode(_payload(), DEV_SECRET, algorithm="HS256")
with pytest.raises(AuthError) as exc:
await verifier.verify(token)
assert "internal-host" not in str(exc.value)
assert "ключ" in str(exc.value)
assert any("internal-host" in m for m in log_messages) # деталь — только в лог
class _JwksHandler(BaseHTTPRequestHandler):
body = b""
def do_GET(self) -> None:
self.send_response(200)
self.send_header("Content-Type", "application/json")
self.end_headers()
self.wfile.write(self.body)
def log_message(self, format: str, *args: Any) -> None: # noqa: A002 - сигнатура базового класса
return
async def test_jwks_real_pyjwkclient_over_loopback(rsa_key: rsa.RSAPrivateKey) -> None:
"""Реальный PyJWKClient: выбор ключа по kid из JWKS, отдаваемого локальным сервером (только loopback)."""
jwk = json.loads(jwt.algorithms.RSAAlgorithm.to_jwk(rsa_key.public_key()))
jwk.update(kid="key-1", use="sig", alg="RS256")
_JwksHandler.body = json.dumps({"keys": [jwk]}).encode()
server = HTTPServer(("127.0.0.1", 0), _JwksHandler)
thread = threading.Thread(target=server.serve_forever, daemon=True)
thread.start()
try:
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"})
assert (await verifier.verify(good)).email == EMAIL
unknown_kid = jwt.encode(_payload(), _pem_private(rsa_key), algorithm="RS256", headers={"kid": "nope"})
with pytest.raises(AuthError, match="ключ"):
await verifier.verify(unknown_kid)
finally:
server.shutdown()
server.server_close()
# --- Фабрика --------------------------------------------------------------------------------------------------
def _settings(monkeypatch: pytest.MonkeyPatch, **auth: Any) -> Settings:
"""Settings без env-файла и без AUTH_*/DEBUG из окружения процесса; обязательные OPENBAO__* — заглушки."""
for key in list(os.environ):
if key.startswith(("OPENBAO__", "AUTH_")) or key == "DEBUG":
monkeypatch.delenv(key)
required = {
"OPENBAO__DATABASE__DATABASE_URL": "postgresql+asyncpg://u:p@localhost:5432/d",
"OPENBAO__MINIO__MINIO_URL": "localhost:9000",
"OPENBAO__MINIO__MINIO_ADMIN_LOGIN": "login",
"OPENBAO__MINIO__MINIO_ADMIN_PASSWORD": "password",
"OPENBAO__MINIO__MINIO_BUCKET": "bucket",
"OPENBAO__NATS__NATS_URL": "nats://localhost:4222",
"OPENBAO__SMB__SMB_HOST": "host",
"OPENBAO__SMB__SMB_PATH": "path",
"OPENBAO__SMB__SMB_LOGIN": "login",
"OPENBAO__SMB__SMB_PASSWORD": "password",
}
for key, value in required.items():
monkeypatch.setenv(key, value)
return Settings(_env_file=None, **auth) # type: ignore[call-arg]
def test_factory_dev_off_and_no_jwks_gives_none(monkeypatch: pytest.MonkeyPatch) -> None:
s = _settings(monkeypatch, AUTH_DEV_ISSUER=False, AUTH_DEV_SECRET=DEV_SECRET, DEBUG=True)
assert build_verifier(s) is None
def test_factory_dev_flag_without_debug_gives_no_dev(monkeypatch: pytest.MonkeyPatch, log_messages: list[str]) -> None:
s = _settings(monkeypatch, AUTH_DEV_ISSUER=True, AUTH_DEV_SECRET=DEV_SECRET, DEBUG=False)
assert build_verifier(s) is None
assert any("AUTH_DEV_ISSUER=true проигнорирован" in m for m in log_messages)
def test_factory_dev_flag_without_debug_falls_back_to_jwks(
monkeypatch: pytest.MonkeyPatch, rsa_key: rsa.RSAPrivateKey
) -> None:
s = _settings(
monkeypatch, AUTH_DEV_ISSUER=True, AUTH_DEV_SECRET=DEV_SECRET, DEBUG=False, AUTH_PUBLIC_KEY=_pem_public(rsa_key)
)
verifier = build_verifier(s)
assert isinstance(verifier, JwksVerifier)
assert not isinstance(verifier, DevHs256Verifier)
def test_factory_both_flags_gives_dev(monkeypatch: pytest.MonkeyPatch) -> None:
s = _settings(monkeypatch, AUTH_DEV_ISSUER=True, AUTH_DEV_SECRET=DEV_SECRET, DEBUG=True, AUTH_EMAIL_CLAIM="mail")
verifier = build_verifier(s)
assert isinstance(verifier, DevHs256Verifier)
token = verifier.issue_token(EMAIL)
assert jwt.decode(token, options={"verify_signature": False})["mail"] == EMAIL
def test_factory_dev_wins_over_jwks_when_both_flags(
monkeypatch: pytest.MonkeyPatch, rsa_key: rsa.RSAPrivateKey
) -> None:
s = _settings(
monkeypatch, AUTH_DEV_ISSUER=True, AUTH_DEV_SECRET=DEV_SECRET, DEBUG=True, AUTH_PUBLIC_KEY=_pem_public(rsa_key)
)
assert isinstance(build_verifier(s), DevHs256Verifier)
def test_factory_dev_flags_without_secret_is_config_error(monkeypatch: pytest.MonkeyPatch) -> None:
s = _settings(monkeypatch, AUTH_DEV_ISSUER=True, DEBUG=True)
with pytest.raises(RuntimeError, match="AUTH_DEV_SECRET"):
build_verifier(s)
def test_factory_public_key_gives_jwks(monkeypatch: pytest.MonkeyPatch, rsa_key: rsa.RSAPrivateKey) -> None:
s = _settings(monkeypatch, AUTH_PUBLIC_KEY=_pem_public(rsa_key))
assert isinstance(build_verifier(s), JwksVerifier)
def test_factory_jwks_url_gives_jwks(monkeypatch: pytest.MonkeyPatch) -> None:
s = _settings(monkeypatch, AUTH_JWKS_URL="https://sso.example/.well-known/jwks.json")
assert isinstance(build_verifier(s), JwksVerifier)
async def test_factory_passes_issuer_audience_and_email_claim(
monkeypatch: pytest.MonkeyPatch, rsa_key: rsa.RSAPrivateKey
) -> None:
s = _settings(
monkeypatch,
AUTH_PUBLIC_KEY=_pem_public(rsa_key),
AUTH_ISSUER="https://sso.raisa",
AUTH_AUDIENCE="arm-core",
AUTH_EMAIL_CLAIM="mail",
)
verifier = build_verifier(s)
assert verifier is not None
priv = _pem_private(rsa_key)
ok = jwt.encode(_payload(iss="https://sso.raisa", aud="arm-core", mail=EMAIL), priv, algorithm="RS256")
assert (await verifier.verify(ok)).email == EMAIL
with pytest.raises(AuthError):
await verifier.verify(jwt.encode(_payload(mail=EMAIL), priv, algorithm="RS256"))
async def test_dev_token_rejected_when_debug_off(monkeypatch: pytest.MonkeyPatch, rsa_key: rsa.RSAPrivateKey) -> None:
"""Токен, подписанный dev-секретом, не принимается ни пустой конфигурацией, ни JWKS-проверяющим (DEBUG=false)."""
token = DevHs256Verifier(DEV_SECRET).issue_token(EMAIL)
prod = _settings(
monkeypatch, AUTH_DEV_ISSUER=True, AUTH_DEV_SECRET=DEV_SECRET, DEBUG=False, AUTH_PUBLIC_KEY=_pem_public(rsa_key)
)
verifier = build_verifier(prod)
assert verifier is not None
with pytest.raises(AuthError):
await verifier.verify(token)