Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions docs/changelog/next_release/283.feature.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
Added OAuth2GatewayProvide
-- by :github:user:`marashka`
19 changes: 18 additions & 1 deletion docs/reference/server/auth/keycloak/index.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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 <https://pypi.org/project/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
9 changes: 4 additions & 5 deletions syncmaster/server/providers/auth/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"]
4 changes: 2 additions & 2 deletions syncmaster/server/providers/auth/dummy_provider.py
Original file line number Diff line number Diff line change
Expand Up @@ -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__}.")
2 changes: 1 addition & 1 deletion syncmaster/server/providers/auth/keycloak_provider.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
88 changes: 88 additions & 0 deletions syncmaster/server/providers/auth/oauth2_gateway_provider.py
Original file line number Diff line number Diff line change
@@ -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__}.")
13 changes: 13 additions & 0 deletions syncmaster/server/settings/auth/oauth2_gateway.py
Original file line number Diff line number Diff line change
@@ -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",
)
1 change: 1 addition & 0 deletions tests/test_unit/test_auth/auth_fixtures/__init__.py
Original file line number Diff line number Diff line change
@@ -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,
Expand Down
27 changes: 27 additions & 0 deletions tests/test_unit/test_auth/auth_fixtures/keycloak_fixture.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
76 changes: 76 additions & 0 deletions tests/test_unit/test_auth/test_oauth2_gateway.py
Original file line number Diff line number Diff line change
@@ -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"}}
Loading