fix(auth): схема bearerAuth в OpenAPI — кнопка Authorize в Swagger UI

Токен читался зависимостью как Header(), поэтому в openapi.json не было securitySchemes и Swagger UI не показывал
Authorize (вопрос фронта 06.10.2026). Теперь HTTPBearer(auto_error=False) со scheme_name bearerAuth; значение
заголовка собирается обратно и идёт в parse_bearer — тексты 401/403 и правила («три части» → 401) не изменились.
Тесты: securitySchemes.bearerAuth есть, все маршруты кроме /dev/token и /healthcheck помечены security.
make check: 1126 passed; интеграция auth/me/dev-token: 53 passed.

Closes #29

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: cedd7915-2efe-41e5-988c-a9117033598b
This commit is contained in:
Andrey Epifancev 2026-10-06 15:43:33 +04:00
parent 4879fdca88
commit 227ab67809
2 changed files with 52 additions and 3 deletions

View File

@ -18,7 +18,8 @@ import uuid
from collections.abc import Awaitable, Callable
from typing import Annotated
from fastapi import Depends, Header, HTTPException, Request, status
from fastapi import Depends, HTTPException, Request, status
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
from src.api.dependencies.db import SessionDep
from src.common.exceptions import AuthenticationFailedError
@ -61,6 +62,27 @@ def _login_forbidden(detail: str, email: str, user_id: uuid.UUID | None = None)
)
# Схема безопасности OpenAPI (issue #29): без неё Swagger UI не показывает Authorize, а заголовок выглядит как обычный
# параметр. `auto_error=False`: нет заголовка или чужая схема → `None`, прежний 401 формирует `parse_bearer`.
BEARER_SCHEME_NAME = "bearerAuth"
bearer_scheme = HTTPBearer(
scheme_name=BEARER_SCHEME_NAME,
bearerFormat="JWT",
auto_error=False,
description=(
"JWT РАИСА (прод) или dev-токен `POST /dev/token` (DEBUG). В Swagger: Authorize → токен без слова Bearer."
),
)
BearerDep = Annotated[HTTPAuthorizationCredentials | None, Depends(bearer_scheme)]
def header_value(credentials: HTTPAuthorizationCredentials | None) -> str | None:
"""Значение заголовка `Authorization` как строка — для `parse_bearer` с прежними правилами (три части → 401)."""
if credentials is None:
return None
return f"{credentials.scheme} {credentials.credentials}"
def parse_bearer(authorization: str | None) -> str:
"""Токен из значения заголовка `Authorization: Bearer <jwt>`; иначе 401 (схема без учёта регистра)."""
if authorization is not None:
@ -84,10 +106,10 @@ def get_token_verifier(request: Request) -> TokenVerifier | None:
async def verified_claims(
verifier: Annotated[TokenVerifier | None, Depends(get_token_verifier)],
authorization: Annotated[str | None, Header()] = None,
credentials: BearerDep = None,
) -> Claims:
"""Claims проверенного токена. Не обращается к БД: все 401 решаются до неё."""
token = parse_bearer(authorization)
token = parse_bearer(header_value(credentials))
if verifier is None:
raise _token_rejected("аутентификация не настроена")
try:

View File

@ -199,3 +199,30 @@ def test_counterparty_mapping_routes_declare_documented_responses(schema: dict[s
assert mapping["200"]["content"]["application/json"]["schema"]["$ref"].endswith("/MapCounterpartyOut")
row = schema["components"]["schemas"]["MapCounterpartyOut"]["properties"]["row"]
assert {"type": "null"} in row["anyOf"]
# --- Схема безопасности (issue #29): Swagger UI показывает Authorize только при securitySchemes в OpenAPI ---
BEARER_SCHEME = "bearerAuth"
# Маршруты без аутентификации: выдача dev-токена и healthcheck
PUBLIC_PATHS = {"/api/v1/dev/token", "/healthcheck"}
def test_openapi_declares_bearer_security_scheme(schema: dict[str, Any]) -> None:
schemes = schema.get("components", {}).get("securitySchemes", {})
bearer = schemes.get(BEARER_SCHEME, {})
assert (bearer.get("type"), bearer.get("scheme"), bearer.get("bearerFormat")) == ("http", "bearer", "JWT"), schemes
assert "Authorize" in bearer.get("description", "") # подсказка для Swagger UI
def test_protected_routes_require_bearer_and_public_do_not(schema: dict[str, Any]) -> None:
missing: list[str] = []
for path, methods in schema["paths"].items():
for method, operation in methods.items():
requirements = operation.get("security", [])
has_bearer = any(BEARER_SCHEME in requirement for requirement in requirements)
if path in PUBLIC_PATHS:
assert not has_bearer, f"{method.upper()} {path} — публичный маршрут помечен security"
elif not has_bearer:
missing.append(f"{method.upper()} {path}")
assert missing == [], f"маршруты без схемы {BEARER_SCHEME} (Authorize в Swagger не подставит токен): {missing}"