From 57290a8d03ba9037230ee205ad488c2a207b10e7 Mon Sep 17 00:00:00 2001 From: Raykov-MS Date: Wed, 2 Sep 2026 15:28:29 +0300 Subject: [PATCH] fix auth --- api/src/api/v1/auth.py | 61 +++---- api/src/api/v1/deps.py | 27 ++- api/src/api/v1/websocket.py | 167 +++++++++--------- api/src/core/public_paths.py | 4 +- api/src/core/security.py | 42 +++++ api/src/domain/schemas.py | 5 +- api/src/services/auth_service.py | 23 ++- api/tests/unit/test_auth_api_prod_branch.py | 179 +++++++++++++------- api/tests/unit/test_auth_service.py | 98 ++++++++++- api/tests/unit/test_public_paths.py | 14 +- 10 files changed, 440 insertions(+), 180 deletions(-) diff --git a/api/src/api/v1/auth.py b/api/src/api/v1/auth.py index ae8e5db..f78f020 100644 --- a/api/src/api/v1/auth.py +++ b/api/src/api/v1/auth.py @@ -2,37 +2,19 @@ from fastapi import APIRouter, Depends, HTTPException, Request, status from sqlalchemy.ext.asyncio import AsyncSession from src.core.config import settings +from src.core.security import decode_keycloak_token from src.db.session import get_db from src.domain.schemas import LoginRequest, RefreshRequest, Token from src.services.auth_service import AuthService -if not settings.DEBUG: - from raisa_fastapi_protected_api import UserInfo, get_user_dependency - router = APIRouter(prefix="/auth", tags=["auth"]) _PROD_USER_NOT_FOUND = "Пользователь не найден в локальной базе данных" _PROD_EMAIL_MISSING = ( "Не удалось идентифицировать пользователя: в токене отсутствует поле email" ) - - -async def _login_via_keycloak_email(db: AsyncSession, user: "UserInfo") -> Token: - if not getattr(user, "email", None): - raise HTTPException( - status_code=status.HTTP_401_UNAUTHORIZED, - detail=_PROD_EMAIL_MISSING, - headers={"WWW-Authenticate": "Bearer"}, - ) - auth_service = AuthService(db) - token = await auth_service.authenticate_user_via_email(user.email) - if not token: - raise HTTPException( - status_code=status.HTTP_401_UNAUTHORIZED, - detail=_PROD_USER_NOT_FOUND, - headers={"WWW-Authenticate": "Bearer"}, - ) - return token +_PROD_TOKEN_INVALID = "Недействительный токен Keycloak" +_PROD_TOKEN_REQUIRED = "Требуется access token Keycloak" if settings.DEBUG: @@ -79,16 +61,35 @@ else: async def login( login_data: LoginRequest, db: AsyncSession = Depends(get_db), - user: UserInfo = Depends(get_user_dependency), ): - return await _login_via_keycloak_email(db, user) - - @router.post("/login-form", response_model=Token) - async def login_form( - db: AsyncSession = Depends(get_db), - user: UserInfo = Depends(get_user_dependency), - ): - return await _login_via_keycloak_email(db, user) + if not login_data.token: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail=_PROD_TOKEN_REQUIRED, + headers={"WWW-Authenticate": "Bearer"}, + ) + payload = decode_keycloak_token(login_data.token) + if payload is None: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail=_PROD_TOKEN_INVALID, + headers={"WWW-Authenticate": "Bearer"}, + ) + if not payload.get("email"): + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail=_PROD_EMAIL_MISSING, + headers={"WWW-Authenticate": "Bearer"}, + ) + auth_service = AuthService(db) + token = await auth_service.authenticate_with_keycloak_token(login_data.token) + if not token: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail=_PROD_USER_NOT_FOUND, + headers={"WWW-Authenticate": "Bearer"}, + ) + return token @router.post("/refresh", response_model=Token) diff --git a/api/src/api/v1/deps.py b/api/src/api/v1/deps.py index ea9679f..e7ba8bf 100644 --- a/api/src/api/v1/deps.py +++ b/api/src/api/v1/deps.py @@ -3,7 +3,7 @@ from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer from sqlalchemy.ext.asyncio import AsyncSession from src.core.config import settings -from src.core.security import verify_token +from src.core.security import decode_keycloak_token, verify_token from src.db.session import get_db from src.db.models.app_user import AppUser from src.db.models.role import UserRoleEnum @@ -45,6 +45,31 @@ async def get_user_by_token( return user +async def resolve_user_from_access_token( + token: str | None, + db: AsyncSession, +) -> AppUser | None: + """Map access token to app_user without raising. Used by WebSocket login.""" + if not token: + return None + if settings.DEBUG: + try: + return await get_user_by_token(token=token, db=db) + except HTTPException: + return None + payload = decode_keycloak_token(token) + if payload is None: + return None + email = payload.get("email") + if not email: + return None + user_repo = UserRepository(db) + user = await user_repo.get_by_email(email) + if user is None or not user.is_active: + return None + return user + + if settings.DEBUG: async def get_current_user( diff --git a/api/src/api/v1/websocket.py b/api/src/api/v1/websocket.py index bb4be5d..cc92752 100644 --- a/api/src/api/v1/websocket.py +++ b/api/src/api/v1/websocket.py @@ -9,7 +9,7 @@ import time from typing import Any, Optional # -from sqlalchemy.exc import SQLAlchemyError +from sqlalchemy.exc import SQLAlchemyError from fastapi import APIRouter, WebSocket, WebSocketDisconnect, status from sqlalchemy.ext.asyncio import AsyncSession @@ -18,14 +18,14 @@ from src.services.rf_project_report_service import RfProjectReportService from src.services.rf_project_report_line_service import RfProjectReportLineService from src.repository.user_repository import UserRepository from src.services.budget_line_service import BudgetLineService -from src.api.v1.deps import get_user_by_token +from src.api.v1.deps import resolve_user_from_access_token from src.db.models.app_user import AppUser from src.db.models.form_type import FormTypeEnum from src.services.budget_form_service import BudgetFormService from src.services.project_service import ProjectService from src.services.sheet_service import SheetService -from src.core.errors import BasicAppException, ValidationsError -from src.core.exception_handlers import map_sqlalchemy_error +from src.core.errors import BasicAppException, ValidationsError +from src.core.exception_handlers import map_sqlalchemy_error from src.db.session import SessionLocal from src.services.user_service import UserService @@ -389,26 +389,26 @@ class FormEventProcess: event_data=event_data["data"], user=curr_user, ) - case "program_updated": - return await self.__update_form4_program( - event_data=event_data["data"], - user=curr_user, - ) - case "program_deleted": - return await self.__delete_form4_program( - event_data=event_data["data"], - user=curr_user, - ) - case "project_updated": - return await self.__update_form4_project( - event_data=event_data["data"], - user=curr_user, - ) - case "project_deleted": - return await self.__delete_form4_project( - event_data=event_data["data"], - user=curr_user, - ) + case "program_updated": + return await self.__update_form4_program( + event_data=event_data["data"], + user=curr_user, + ) + case "program_deleted": + return await self.__delete_form4_program( + event_data=event_data["data"], + user=curr_user, + ) + case "project_updated": + return await self.__update_form4_project( + event_data=event_data["data"], + user=curr_user, + ) + case "project_deleted": + return await self.__delete_form4_project( + event_data=event_data["data"], + user=curr_user, + ) case _: pass @@ -530,48 +530,48 @@ class FormEventProcess: program_id=event_data["program_id"], user=user, ) - - async def __update_form4_program( - self, - event_data: dict, - user: AppUser, - ) -> tuple[int, list[tuple]] | None: - return await self.sheet_service.update_form4_program( - program_id=event_data["program_id"], - name=event_data["name"], - user=user, - ) - - async def __delete_form4_program( - self, - event_data: dict, - user: AppUser, - ) -> tuple[int, list[tuple]] | None: - return await self.sheet_service.delete_form4_program( - program_id=event_data["program_id"], - user=user, - ) - - async def __update_form4_project( - self, - event_data: dict, - user: AppUser, - ) -> tuple[int, list[tuple]] | None: - return await self.sheet_service.update_form4_project( - project_id=event_data["project_id"], - name=event_data["name"], - user=user, - ) - - async def __delete_form4_project( - self, - event_data: dict, - user: AppUser, - ) -> tuple[int, list[tuple]] | None: - return await self.sheet_service.delete_form4_project( - project_id=event_data["project_id"], - user=user, - ) + + async def __update_form4_program( + self, + event_data: dict, + user: AppUser, + ) -> tuple[int, list[tuple]] | None: + return await self.sheet_service.update_form4_program( + program_id=event_data["program_id"], + name=event_data["name"], + user=user, + ) + + async def __delete_form4_program( + self, + event_data: dict, + user: AppUser, + ) -> tuple[int, list[tuple]] | None: + return await self.sheet_service.delete_form4_program( + program_id=event_data["program_id"], + user=user, + ) + + async def __update_form4_project( + self, + event_data: dict, + user: AppUser, + ) -> tuple[int, list[tuple]] | None: + return await self.sheet_service.update_form4_project( + project_id=event_data["project_id"], + name=event_data["name"], + user=user, + ) + + async def __delete_form4_project( + self, + event_data: dict, + user: AppUser, + ) -> tuple[int, list[tuple]] | None: + return await self.sheet_service.delete_form4_project( + project_id=event_data["project_id"], + user=user, + ) async def __del_row( self, @@ -755,7 +755,10 @@ async def login(websocket: WebSocket, **kwargs) -> int: return None async with get_db_session() as db: - user = await get_user_by_token(token=user_data["data"].get("token"), db=db) + user = await resolve_user_from_access_token( + token=user_data["data"].get("token"), + db=db, + ) if not user: return None manager.set_user( @@ -936,10 +939,10 @@ async def process_websocket( "row_added", "program_added", "project_added", - "program_updated", - "project_updated", - "program_deleted", - "project_deleted", + "program_updated", + "project_updated", + "program_deleted", + "project_deleted", "row_deleted", ]: await manager.broadcast_to_all( @@ -962,17 +965,17 @@ async def process_websocket( data=data, websocket=websocket, ) - except SQLAlchemyError as e: - _, code, message, field = map_sqlalchemy_error(e) - data["error"] = { - "code": code, - "field": field, - "message": message, - } - await manager.send_back( - data=data, - websocket=websocket, - ) + except SQLAlchemyError as e: + _, code, message, field = map_sqlalchemy_error(e) + data["error"] = { + "code": code, + "field": field, + "message": message, + } + await manager.send_back( + data=data, + websocket=websocket, + ) except SheetValidationError as e: data["error"] = e.message await manager.send_back( diff --git a/api/src/core/public_paths.py b/api/src/core/public_paths.py index 1b671c2..9d515ac 100644 --- a/api/src/core/public_paths.py +++ b/api/src/core/public_paths.py @@ -1,4 +1,4 @@ -"""Публичные пути для AuthorizationMiddleware: SPA и infra, без /api.""" +"""Публичные пути для AuthorizationMiddleware: SPA, infra и POST /api/v1/auth/login.""" ABSOLUTE_PUBLIC_PATHS = ( "", @@ -7,6 +7,7 @@ ABSOLUTE_PUBLIC_PATHS = ( "/healthcheck2", "/healthz", "/readyz", + "/api/v1/auth/login", ) START_PUBLIC_PREFIXES = ( @@ -15,6 +16,7 @@ START_PUBLIC_PREFIXES = ( "/openapi.json", "/docs", "/docs-local", + "/api/v1/ws", "/login", "/task", "/project", diff --git a/api/src/core/security.py b/api/src/core/security.py index 2b394c3..db9218b 100644 --- a/api/src/core/security.py +++ b/api/src/core/security.py @@ -1,6 +1,7 @@ from datetime import datetime, timedelta, timezone from typing import Optional +import httpx from argon2 import PasswordHasher from argon2.exceptions import InvalidHashError, VerificationError, VerifyMismatchError from jose import JWTError, jwt @@ -9,6 +10,9 @@ from src.core.config import settings pwd_context = PasswordHasher() +_KEYCLOAK_ALGORITHMS = ["RS256"] +_jwks_cache: dict | None = None + def verify_password(plain_password: str, hashed_password: Optional[str]) -> bool: if not hashed_password: @@ -53,3 +57,41 @@ def verify_token(token: str) -> Optional[dict]: ) except JWTError: return None + + +def clear_jwks_cache() -> None: + global _jwks_cache + _jwks_cache = None + + +def _get_jwks(*, force_refresh: bool = False) -> dict: + global _jwks_cache + if _jwks_cache is None or force_refresh: + response = httpx.get(settings.JWKS_URL, timeout=10.0) + response.raise_for_status() + _jwks_cache = response.json() + return _jwks_cache + + +def decode_keycloak_token(token: str) -> Optional[dict]: + """Проверяет Keycloak JWT по JWKS и возвращает payload.""" + try: + header = jwt.get_unverified_header(token) + kid = header.get("kid") + jwks = _get_jwks() + keys = jwks.get("keys") or [] + key_data = next((item for item in keys if item.get("kid") == kid), None) + if key_data is None: + jwks = _get_jwks(force_refresh=True) + keys = jwks.get("keys") or [] + key_data = next((item for item in keys if item.get("kid") == kid), None) + if key_data is None: + return None + return jwt.decode( + token, + key_data, + algorithms=_KEYCLOAK_ALGORITHMS, + options={"verify_aud": False}, + ) + except (JWTError, httpx.HTTPError, ValueError, KeyError, StopIteration): + return None diff --git a/api/src/domain/schemas.py b/api/src/domain/schemas.py index d9d8ad8..1a914ee 100644 --- a/api/src/domain/schemas.py +++ b/api/src/domain/schemas.py @@ -227,8 +227,9 @@ class Token(BaseModel): class LoginRequest(BaseModel): - username: str - password: str + username: str = "" + password: str = "" + token: Optional[str] = None class RefreshRequest(BaseModel): diff --git a/api/src/services/auth_service.py b/api/src/services/auth_service.py index 3af3f14..82a80f1 100644 --- a/api/src/services/auth_service.py +++ b/api/src/services/auth_service.py @@ -2,7 +2,12 @@ from typing import Optional from sqlalchemy.ext.asyncio import AsyncSession -from src.core.security import create_access_token, create_refresh_token, verify_token +from src.core.security import ( + create_access_token, + create_refresh_token, + decode_keycloak_token, + verify_token, +) from src.domain.schemas import Token from src.repository.user_repository import UserRepository @@ -36,6 +41,22 @@ class AuthService: token_type="bearer", ) + async def authenticate_with_keycloak_token(self, token: str) -> Optional[Token]: + payload = decode_keycloak_token(token) + if payload is None: + return None + email = payload.get("email") + if not email: + return None + user = await self.user_repo.authenticate_via_email(email) + if not user or not user.is_active: + return None + return Token( + access_token=token, + refresh_token=None, + token_type="bearer", + ) + async def refresh_token(self, refresh_token: str) -> Optional[Token]: payload = verify_token(refresh_token) if payload is None: diff --git a/api/tests/unit/test_auth_api_prod_branch.py b/api/tests/unit/test_auth_api_prod_branch.py index 4249dc8..ff4c30a 100644 --- a/api/tests/unit/test_auth_api_prod_branch.py +++ b/api/tests/unit/test_auth_api_prod_branch.py @@ -3,6 +3,7 @@ import sys import types from unittest.mock import AsyncMock, MagicMock +import pytest from fastapi import Depends, FastAPI from fastapi.testclient import TestClient from sqlalchemy.ext.asyncio import AsyncSession @@ -69,30 +70,30 @@ def test_auth_login_non_debug_branch_success_and_unauthorized(monkeypatch): "src.api.v1.auth.AuthService", MagicMock(return_value=mock_auth_service), ) + m.setattr( + auth_module, + "decode_keycloak_token", + lambda _token: {"email": "prod-user@example.com"}, + ) client = TestClient(app) - mock_auth_service.authenticate_user_via_email = AsyncMock( + mock_auth_service.authenticate_with_keycloak_token = AsyncMock( return_value={ - "access_token": "a", - "refresh_token": "r", + "access_token": "kc-token", "token_type": "bearer", } ) - response = client.post( - "/auth/login", - json={"username": "ignored", "password": "ignored"}, - ) + response = client.post("/auth/login", json={"token": "kc-token"}) assert response.status_code == 200 - assert response.json()["access_token"] == "a" - mock_auth_service.authenticate_user_via_email.assert_awaited_once_with( - "prod-user@example.com" + assert response.json()["access_token"] == "kc-token" + mock_auth_service.authenticate_with_keycloak_token.assert_awaited_once_with( + "kc-token" ) - mock_auth_service.authenticate_user_via_email = AsyncMock(return_value=None) - response = client.post( - "/auth/login", - json={"username": "ignored", "password": "ignored"}, + mock_auth_service.authenticate_with_keycloak_token = AsyncMock( + return_value=None ) + response = client.post("/auth/login", json={"token": "kc-token"}) assert response.status_code == 401 assert response.json()["message"] == ( "Пользователь не найден в локальной базе данных" @@ -101,52 +102,11 @@ def test_auth_login_non_debug_branch_success_and_unauthorized(monkeypatch): _restore_module(monkeypatch, "src.api.v1.auth", original_debug, previous) -def test_auth_login_form_non_debug_uses_keycloak_email(monkeypatch): +def test_auth_login_non_debug_requires_token(monkeypatch): original_debug, previous, auth_module = _reload_with_prod_auth( monkeypatch, "src.api.v1.auth" ) - try: - mock_auth_service = MagicMock() - mock_auth_service.authenticate_user = AsyncMock(return_value=None) - mock_auth_service.authenticate_user_via_email = AsyncMock( - return_value={ - "access_token": "a", - "refresh_token": "r", - "token_type": "bearer", - } - ) - - app = FastAPI() - register_exception_handlers(app) - app.include_router(auth_module.router) - - async def mock_get_db(): - return AsyncMock(spec=AsyncSession) - - app.dependency_overrides = {get_db: mock_get_db} - - with monkeypatch.context() as m: - m.setattr( - "src.api.v1.auth.AuthService", - MagicMock(return_value=mock_auth_service), - ) - client = TestClient(app) - response = client.post("/auth/login-form") - assert response.status_code == 200 - mock_auth_service.authenticate_user_via_email.assert_awaited_once_with( - "prod-user@example.com" - ) - mock_auth_service.authenticate_user.assert_not_awaited() - finally: - _restore_module(monkeypatch, "src.api.v1.auth", original_debug, previous) - - -def test_auth_login_non_debug_rejects_missing_email(monkeypatch): - original_debug, previous, auth_module = _reload_with_prod_auth( - monkeypatch, "src.api.v1.auth", email="" - ) - try: app = FastAPI() register_exception_handlers(app) @@ -159,10 +119,74 @@ def test_auth_login_non_debug_rejects_missing_email(monkeypatch): client = TestClient(app) response = client.post( "/auth/login", - json={"username": "ignored", "password": "ignored"}, + json={"username": "admin", "password": "admin123"}, ) assert response.status_code == 401 - assert "email" in response.json()["message"] + assert "Keycloak" in response.json()["message"] + finally: + _restore_module(monkeypatch, "src.api.v1.auth", original_debug, previous) + + +def test_auth_login_non_debug_rejects_missing_email(monkeypatch): + original_debug, previous, auth_module = _reload_with_prod_auth( + monkeypatch, "src.api.v1.auth" + ) + + try: + app = FastAPI() + register_exception_handlers(app) + app.include_router(auth_module.router) + + async def mock_get_db(): + return AsyncMock(spec=AsyncSession) + + app.dependency_overrides = {get_db: mock_get_db} + with monkeypatch.context() as m: + m.setattr(auth_module, "decode_keycloak_token", lambda _token: {"sub": "x"}) + client = TestClient(app) + response = client.post("/auth/login", json={"token": "kc-token"}) + assert response.status_code == 401 + assert "email" in response.json()["message"] + finally: + _restore_module(monkeypatch, "src.api.v1.auth", original_debug, previous) + + +def test_auth_login_non_debug_rejects_invalid_token(monkeypatch): + original_debug, previous, auth_module = _reload_with_prod_auth( + monkeypatch, "src.api.v1.auth" + ) + + try: + app = FastAPI() + register_exception_handlers(app) + app.include_router(auth_module.router) + + async def mock_get_db(): + return AsyncMock(spec=AsyncSession) + + app.dependency_overrides = {get_db: mock_get_db} + with monkeypatch.context() as m: + m.setattr(auth_module, "decode_keycloak_token", lambda _token: None) + client = TestClient(app) + response = client.post("/auth/login", json={"token": "bad"}) + assert response.status_code == 401 + assert response.json()["message"] == "Недействительный токен Keycloak" + finally: + _restore_module(monkeypatch, "src.api.v1.auth", original_debug, previous) + + +def test_auth_login_form_not_available_in_prod(monkeypatch): + original_debug, previous, auth_module = _reload_with_prod_auth( + monkeypatch, "src.api.v1.auth" + ) + + try: + app = FastAPI() + register_exception_handlers(app) + app.include_router(auth_module.router) + client = TestClient(app) + response = client.post("/auth/login-form") + assert response.status_code == 404 finally: _restore_module(monkeypatch, "src.api.v1.auth", original_debug, previous) @@ -230,3 +254,42 @@ def test_get_current_user_non_debug_rejects_inactive(monkeypatch): assert response.json()["message"] == "Пользователь не найден" finally: _restore_module(monkeypatch, "src.api.v1.deps", original_debug, previous) + + +@pytest.mark.asyncio +async def test_resolve_user_from_access_token_prod_maps_email(monkeypatch): + original_debug, previous, deps_module = _reload_with_prod_auth( + monkeypatch, "src.api.v1.deps" + ) + try: + db_user = MagicMock(email="prod-user@example.com", is_active=True) + mock_repo = MagicMock() + mock_repo.get_by_email = AsyncMock(return_value=db_user) + monkeypatch.setattr( + deps_module, "decode_keycloak_token", lambda _token: {"email": "prod-user@example.com"} + ) + monkeypatch.setattr(deps_module, "UserRepository", MagicMock(return_value=mock_repo)) + + user = await deps_module.resolve_user_from_access_token( + "kc-token", AsyncMock(spec=AsyncSession) + ) + + assert user is db_user + mock_repo.get_by_email.assert_awaited_once_with("prod-user@example.com") + finally: + _restore_module(monkeypatch, "src.api.v1.deps", original_debug, previous) + + +@pytest.mark.asyncio +async def test_resolve_user_from_access_token_prod_rejects_hs256(monkeypatch): + original_debug, previous, deps_module = _reload_with_prod_auth( + monkeypatch, "src.api.v1.deps" + ) + try: + monkeypatch.setattr(deps_module, "decode_keycloak_token", lambda _token: None) + user = await deps_module.resolve_user_from_access_token( + "local-hs256", AsyncMock(spec=AsyncSession) + ) + assert user is None + finally: + _restore_module(monkeypatch, "src.api.v1.deps", original_debug, previous) diff --git a/api/tests/unit/test_auth_service.py b/api/tests/unit/test_auth_service.py index e9e7842..e62ac85 100644 --- a/api/tests/unit/test_auth_service.py +++ b/api/tests/unit/test_auth_service.py @@ -1,11 +1,67 @@ +import base64 from unittest.mock import AsyncMock, MagicMock import pytest +from cryptography.hazmat.primitives import serialization +from cryptography.hazmat.primitives.asymmetric import rsa +from jose import jwt as jose_jwt -from src.core.security import create_refresh_token, verify_token +from src.core.security import ( + clear_jwks_cache, + create_refresh_token, + decode_keycloak_token, + verify_token, +) from src.services.auth_service import AuthService +def _b64u_int(value: int) -> str: + raw = value.to_bytes((value.bit_length() + 7) // 8, "big") + return base64.urlsafe_b64encode(raw).rstrip(b"=").decode() + + +def test_decode_keycloak_token_rejects_garbage(monkeypatch): + clear_jwks_cache() + monkeypatch.setattr("src.core.security._get_jwks", lambda **_kwargs: {"keys": []}) + assert decode_keycloak_token("not-a-jwt") is None + + +def test_decode_keycloak_token_accepts_valid_rs256(monkeypatch): + private_key = rsa.generate_private_key(public_exponent=65537, key_size=2048) + numbers = private_key.public_key().public_numbers() + kid = "test-key" + jwks = { + "keys": [ + { + "kty": "RSA", + "kid": kid, + "use": "sig", + "alg": "RS256", + "n": _b64u_int(numbers.n), + "e": _b64u_int(numbers.e), + } + ] + } + pem = private_key.private_bytes( + encoding=serialization.Encoding.PEM, + format=serialization.PrivateFormat.PKCS8, + encryption_algorithm=serialization.NoEncryption(), + ) + token = jose_jwt.encode( + {"email": "admin@example.com", "sub": "admin"}, + pem, + algorithm="RS256", + headers={"kid": kid}, + ) + clear_jwks_cache() + monkeypatch.setattr("src.core.security._get_jwks", lambda **_kwargs: jwks) + + payload = decode_keycloak_token(token) + + assert payload is not None + assert payload["email"] == "admin@example.com" + + @pytest.mark.asyncio async def test_authenticate_user_returns_token(monkeypatch): db = AsyncMock() @@ -105,3 +161,43 @@ async def test_authenticate_user_via_email_rejects_inactive(): token = await service.authenticate_user_via_email("admin@example.com") assert token is None + + +@pytest.mark.asyncio +async def test_authenticate_with_keycloak_token_passthrough(monkeypatch): + db = AsyncMock() + service = AuthService(db) + service.user_repo = MagicMock() + service.user_repo.authenticate_via_email = AsyncMock( + return_value=MagicMock( + username="admin", email="admin@example.com", is_active=True + ) + ) + monkeypatch.setattr( + "src.services.auth_service.decode_keycloak_token", + lambda _token: {"email": "admin@example.com"}, + ) + + raw = "keycloak-access-token" + token = await service.authenticate_with_keycloak_token(raw) + + assert token is not None + assert token.access_token == raw + assert token.refresh_token is None + service.user_repo.authenticate_via_email.assert_awaited_once_with( + "admin@example.com" + ) + + +@pytest.mark.asyncio +async def test_authenticate_with_keycloak_token_rejects_invalid(monkeypatch): + db = AsyncMock() + service = AuthService(db) + monkeypatch.setattr( + "src.services.auth_service.decode_keycloak_token", + lambda _token: None, + ) + + token = await service.authenticate_with_keycloak_token("bad") + + assert token is None diff --git a/api/tests/unit/test_public_paths.py b/api/tests/unit/test_public_paths.py index 1ce4d3a..0a03bf0 100644 --- a/api/tests/unit/test_public_paths.py +++ b/api/tests/unit/test_public_paths.py @@ -5,16 +5,22 @@ def test_login_page_is_public_without_root(): specs = public_endpoint_specs("") assert ("start", "/login") in specs assert ("absolute", "/") in specs + assert ("absolute", "/api/v1/auth/login") in specs def test_login_page_is_public_with_root_path(): specs = public_endpoint_specs("/aurora/apps/fastapi-tsygankov-test") assert ("start", "/login") in specs assert ("start", "/aurora/apps/fastapi-tsygankov-test/login") in specs + assert ("absolute", "/aurora/apps/fastapi-tsygankov-test/api/v1/auth/login") in specs -def test_api_routes_are_not_public(): +def test_api_routes_except_login_and_ws_are_not_public(): specs = public_endpoint_specs("/aurora/apps/fastapi-tsygankov-test") - paths = [path for _, path in specs] - assert not any(path == "/api" or path.startswith("/api/") for path in paths) - assert not any("/api/v1" in path for path in paths) + api_paths = [path for _, path in specs if "/api/" in path] + assert api_paths == [ + "/api/v1/auth/login", + "/aurora/apps/fastapi-tsygankov-test/api/v1/auth/login", + "/api/v1/ws", + "/aurora/apps/fastapi-tsygankov-test/api/v1/ws", + ]