diff --git a/docs/changelog/next_release/283.feature.rst b/docs/changelog/next_release/283.feature.rst new file mode 100644 index 00000000..bb8f8a4c --- /dev/null +++ b/docs/changelog/next_release/283.feature.rst @@ -0,0 +1,2 @@ +Added OAuth2GatewayProvide +-- by :github:user:`marashka` \ No newline at end of file diff --git a/docs/reference/server/auth/keycloak/index.rst b/docs/reference/server/auth/keycloak/index.rst index 3b8c8f38..92e838b4 100644 --- a/docs/reference/server/auth/keycloak/index.rst +++ b/docs/reference/server/auth/keycloak/index.rst @@ -81,9 +81,26 @@ Basic configuration .. autopydantic_model:: syncmaster.server.settings.auth.keycloak.KeycloakSettings .. autopydantic_model:: syncmaster.server.settings.auth.jwt.JWTSettings + +OAuth2 Gateway Provider +----------- +In case of using an OAuth2 Gateway, all API requests will come with an Authorization: Bearer header. For this scenario, Syncmaster provides an alternative authentication provider called OAuth2GatewayProvider. This provider works as follows: + +- It extracts the access token from the Authorization header. +- It inspects the token in Keycloak. +- It searches for the user in the Syncmaster database and creates it if not found. + +This provider ensures integration with OAuth2 Gateway and maintains the standard authorization flow as described in the Keycloak Auth Provider section. It also uses the `python-keycloak `_ library for interactions with the Keycloak server and handles the token exchange process similarly. + +**Configuration** + +OAuth2GatewayProvider uses the same configuration models as KeycloakAuthProvider — namely: + +.. autopydantic_model:: syncmaster.server.settings.auth.oauth2_gateway.OAuth2GatewayProviderSettings + .. toctree:: :maxdepth: 1 :caption: Keycloak :hidden: - local_installation + local_installation \ No newline at end of file diff --git a/syncmaster/server/providers/auth/__init__.py b/syncmaster/server/providers/auth/__init__.py index fd54d8d9..61eb9138 100644 --- a/syncmaster/server/providers/auth/__init__.py +++ b/syncmaster/server/providers/auth/__init__.py @@ -3,9 +3,8 @@ from syncmaster.server.providers.auth.base_provider import AuthProvider from syncmaster.server.providers.auth.dummy_provider import DummyAuthProvider from syncmaster.server.providers.auth.keycloak_provider import KeycloakAuthProvider +from syncmaster.server.providers.auth.oauth2_gateway_provider import ( + OAuth2GatewayProvider, +) -__all__ = [ - "AuthProvider", - "DummyAuthProvider", - "KeycloakAuthProvider", -] +__all__ = ["AuthProvider", "DummyAuthProvider", "KeycloakAuthProvider", "OAuth2GatewayProvider"] diff --git a/syncmaster/server/providers/auth/dummy_provider.py b/syncmaster/server/providers/auth/dummy_provider.py index ae0ed25f..5ae7bbcd 100644 --- a/syncmaster/server/providers/auth/dummy_provider.py +++ b/syncmaster/server/providers/auth/dummy_provider.py @@ -107,7 +107,7 @@ async def get_token_authorization_code_grant( client_id: str | None = None, client_secret: str | None = None, ) -> dict[str, Any]: - raise NotImplementedError("Authorization code grant is not supported by DummyAuthProvider") + raise NotImplementedError(f"Authorization code grant is not supported by {self.__class__.__name__}.") async def logout(self, user: User, refresh_token: str | None) -> None: - raise NotImplementedError("Logout is not supported by DummyAuthProvider") + raise NotImplementedError(f"Logout is not supported by {self.__class__.__name__}.") diff --git a/syncmaster/server/providers/auth/keycloak_provider.py b/syncmaster/server/providers/auth/keycloak_provider.py index 6e6db37b..dd8de6fb 100644 --- a/syncmaster/server/providers/auth/keycloak_provider.py +++ b/syncmaster/server/providers/auth/keycloak_provider.py @@ -52,7 +52,7 @@ async def get_token_password_grant( client_id: str | None = None, client_secret: str | None = None, ) -> dict[str, Any]: - raise NotImplementedError("Password grant is not supported by KeycloakAuthProvider.") + raise NotImplementedError(f"Password grant is not supported by {self.__class__.__name__}.") async def get_token_authorization_code_grant( self, diff --git a/syncmaster/server/providers/auth/oauth2_gateway_provider.py b/syncmaster/server/providers/auth/oauth2_gateway_provider.py new file mode 100644 index 00000000..aa7c81bd --- /dev/null +++ b/syncmaster/server/providers/auth/oauth2_gateway_provider.py @@ -0,0 +1,88 @@ +# SPDX-FileCopyrightText: 2023-2025 MTS PJSC +# SPDX-License-Identifier: Apache-2.0 +import logging +from typing import Annotated, Any + +from fastapi import Depends, FastAPI, Request + +from syncmaster.db.models import User +from syncmaster.exceptions import EntityNotFoundError +from syncmaster.exceptions.auth import AuthorizationError +from syncmaster.server.dependencies import Stub +from syncmaster.server.providers.auth.base_provider import AuthProvider +from syncmaster.server.providers.auth.keycloak_provider import ( + KeycloakAuthProvider, + KeycloakOperationError, +) +from syncmaster.server.services.unit_of_work import UnitOfWork +from syncmaster.server.settings.auth.oauth2_gateway import OAuth2GatewayProviderSettings + +log = logging.getLogger(__name__) + + +class OAuth2GatewayProvider(KeycloakAuthProvider): + def __init__( + self, + settings: Annotated[OAuth2GatewayProviderSettings, Depends(Stub(OAuth2GatewayProviderSettings))], + unit_of_work: Annotated[UnitOfWork, Depends()], + ) -> None: + super().__init__(settings, unit_of_work) # type: ignore[arg-type] + + @classmethod + def setup(cls, app: FastAPI) -> FastAPI: + settings = OAuth2GatewayProviderSettings.model_validate( + app.state.settings.auth.model_dump(exclude={"provider"}), + ) + log.info("Using %s provider with settings:\n%s", cls.__name__, settings) + app.dependency_overrides[AuthProvider] = cls + app.dependency_overrides[OAuth2GatewayProviderSettings] = lambda: settings + return app + + async def get_current_user(self, access_token: str | None, request: Request) -> User: # noqa: WPS231, WPS217 + + if not access_token: + log.debug("No access token found in request") + raise AuthorizationError("Missing auth credentials") + + try: + token_info = await self.keycloak_openid.a_introspect(access_token) + except KeycloakOperationError as e: + log.info("Failed to introspect token: %s", e) + raise AuthorizationError("Invalid token payload") + + if token_info["active"] is False: + raise AuthorizationError("Token is not active") + + # these names are hardcoded in keycloak: + # https://github.com/keycloak/keycloak/blob/3ca3a4ad349b4d457f6829eaf2ae05f1e01408be/core/src/main/java/org/keycloak/representations/IDToken.java + # TODO: make sure which fields are guaranteed + login = token_info["preferred_username"] + email = token_info.get("email") + first_name = token_info.get("given_name") + middle_name = token_info.get("middle_name") + last_name = token_info.get("family_name") + + async with self._uow: + try: + user = await self._uow.user.read_by_username(login) + except EntityNotFoundError: + user = await self._uow.user.create( + username=login, + email=email, + first_name=first_name, + middle_name=middle_name, + last_name=last_name, + ) + return user + + async def get_token_authorization_code_grant( + self, + code: str, + scopes: list[str] | None = None, + client_id: str | None = None, + client_secret: str | None = None, + ) -> dict[str, Any]: + raise NotImplementedError(f"Authorization code grant is not supported by {self.__class__.__name__}.") + + async def logout(self, user: User, refresh_token: str | None) -> None: + raise NotImplementedError(f"Logout is not supported by {self.__class__.__name__}.") diff --git a/syncmaster/server/settings/auth/oauth2_gateway.py b/syncmaster/server/settings/auth/oauth2_gateway.py new file mode 100644 index 00000000..6696df4b --- /dev/null +++ b/syncmaster/server/settings/auth/oauth2_gateway.py @@ -0,0 +1,13 @@ +# SPDX-FileCopyrightText: 2023-2025 MTS PJSC +# SPDX-License-Identifier: Apache-2.0 +from pydantic import BaseModel, Field + +from syncmaster.server.settings.auth.keycloak import KeycloakSettings + + +class OAuth2GatewayProviderSettings(BaseModel): + """Settings related to Keycloak interaction.""" + + keycloak: KeycloakSettings = Field( + description="Keycloak settings", + ) diff --git a/tests/test_unit/test_auth/auth_fixtures/__init__.py b/tests/test_unit/test_auth/auth_fixtures/__init__.py index e1357aa6..383638bb 100644 --- a/tests/test_unit/test_auth/auth_fixtures/__init__.py +++ b/tests/test_unit/test_auth/auth_fixtures/__init__.py @@ -1,6 +1,7 @@ from tests.test_unit.test_auth.auth_fixtures.keycloak_fixture import ( create_session_cookie, mock_keycloak_api, + mock_keycloak_introspect_token, mock_keycloak_logout, mock_keycloak_realm, mock_keycloak_token_refresh, diff --git a/tests/test_unit/test_auth/auth_fixtures/keycloak_fixture.py b/tests/test_unit/test_auth/auth_fixtures/keycloak_fixture.py index 50cacedc..30d4c993 100644 --- a/tests/test_unit/test_auth/auth_fixtures/keycloak_fixture.py +++ b/tests/test_unit/test_auth/auth_fixtures/keycloak_fixture.py @@ -181,3 +181,30 @@ def mock_keycloak_logout(settings, mock_keycloak_api): logout_url = f"{realm_url}/protocol/openid-connect/logout" mock_keycloak_api.post(logout_url).respond(status_code=204) + + +@pytest.fixture +def mock_keycloak_introspect_token(settings, mock_keycloak_api): + def _mock_keycloak_introspect_token(user): + keycloak_settings = settings.auth.model_dump()["keycloak"] + server_url = keycloak_settings["server_url"] + realm_name = keycloak_settings["client_id"] + realm_url = f"{server_url}/realms/{realm_name}" + + payload = { + "preferred_username": user.username, + "email": user.email, + "given_name": user.first_name, + "middle_name": user.middle_name, + "family_name": user.last_name, + "active": user.is_active, + } + introspect_url = f"{realm_url}/protocol/openid-connect/token/introspect" + + mock_keycloak_api.post(introspect_url).respond( + json=payload, + status_code=200, + content_type="application/json", + ) + + return _mock_keycloak_introspect_token diff --git a/tests/test_unit/test_auth/test_oauth2_gateway.py b/tests/test_unit/test_auth/test_oauth2_gateway.py new file mode 100644 index 00000000..248d902a --- /dev/null +++ b/tests/test_unit/test_auth/test_oauth2_gateway.py @@ -0,0 +1,76 @@ +import pytest +from httpx import AsyncClient + +from syncmaster.server.settings import ServerAppSettings as Settings +from tests.mocks import MockUser + +OAuth2GatewayProvider = "syncmaster.server.providers.auth.oauth2_gateway_provider.OAuth2GatewayProvider" +pytestmark = [pytest.mark.asyncio, pytest.mark.server] + + +@pytest.mark.parametrize( + "settings", + [ + { + "auth": { + "provider": OAuth2GatewayProvider, + }, + }, + ], + indirect=True, +) +async def test_get_keycloak_token_active( + client: AsyncClient, + simple_user: MockUser, + settings: Settings, + mock_keycloak_introspect_token, +): + + mock_keycloak_introspect_token(simple_user) + + headers = { + "Authorization": "Bearer token", + } + response = await client.get( + f"/v1/users/{simple_user.id}", + headers=headers, + ) + + assert response.status_code == 200, response.json() + assert response.json() == { + "id": simple_user.id, + "is_superuser": simple_user.is_superuser, + "username": simple_user.username, + } + + +@pytest.mark.parametrize( + "settings", + [ + { + "auth": { + "provider": OAuth2GatewayProvider, + }, + }, + ], + indirect=True, +) +async def test_get_keycloak_token_inactive( + client: AsyncClient, + simple_user: MockUser, + inactive_user: MockUser, + settings: Settings, + mock_keycloak_introspect_token, +): + mock_keycloak_introspect_token(inactive_user) + + headers = { + "Authorization": "Bearer token", + } + + response = await client.get( + f"/v1/users/{simple_user.id}", + headers=headers, + ) + assert response.status_code == 401, response.json() + assert response.json() == {"error": {"code": "unauthorized", "details": None, "message": "Not authenticated"}}