Skip to content

Commit 676bb9f

Browse files
marashkaMarat Akhmetov
andauthored
[DOP-29537] added OAuth2GatewayProvider (#283)
* [DOP-29537] added OAuth2GatewayProvider * [DOP-29537] added settings for OAuth2GatewayProvider * [DOP-29537] added documentation for OAuth2GatewayProvider --------- Co-authored-by: Marat Akhmetov <[email protected]>
1 parent 085e567 commit 676bb9f

File tree

10 files changed

+232
-9
lines changed

10 files changed

+232
-9
lines changed
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
Added OAuth2GatewayProvide
2+
-- by :github:user:`marashka`

docs/reference/server/auth/keycloak/index.rst

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -81,9 +81,26 @@ Basic configuration
8181
.. autopydantic_model:: syncmaster.server.settings.auth.keycloak.KeycloakSettings
8282
.. autopydantic_model:: syncmaster.server.settings.auth.jwt.JWTSettings
8383

84+
85+
OAuth2 Gateway Provider
86+
-----------
87+
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:
88+
89+
- It extracts the access token from the Authorization header.
90+
- It inspects the token in Keycloak.
91+
- It searches for the user in the Syncmaster database and creates it if not found.
92+
93+
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.
94+
95+
**Configuration**
96+
97+
OAuth2GatewayProvider uses the same configuration models as KeycloakAuthProvider — namely:
98+
99+
.. autopydantic_model:: syncmaster.server.settings.auth.oauth2_gateway.OAuth2GatewayProviderSettings
100+
84101
.. toctree::
85102
:maxdepth: 1
86103
:caption: Keycloak
87104
:hidden:
88105

89-
local_installation
106+
local_installation

syncmaster/server/providers/auth/__init__.py

Lines changed: 4 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -3,9 +3,8 @@
33
from syncmaster.server.providers.auth.base_provider import AuthProvider
44
from syncmaster.server.providers.auth.dummy_provider import DummyAuthProvider
55
from syncmaster.server.providers.auth.keycloak_provider import KeycloakAuthProvider
6+
from syncmaster.server.providers.auth.oauth2_gateway_provider import (
7+
OAuth2GatewayProvider,
8+
)
69

7-
__all__ = [
8-
"AuthProvider",
9-
"DummyAuthProvider",
10-
"KeycloakAuthProvider",
11-
]
10+
__all__ = ["AuthProvider", "DummyAuthProvider", "KeycloakAuthProvider", "OAuth2GatewayProvider"]

syncmaster/server/providers/auth/dummy_provider.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -107,7 +107,7 @@ async def get_token_authorization_code_grant(
107107
client_id: str | None = None,
108108
client_secret: str | None = None,
109109
) -> dict[str, Any]:
110-
raise NotImplementedError("Authorization code grant is not supported by DummyAuthProvider")
110+
raise NotImplementedError(f"Authorization code grant is not supported by {self.__class__.__name__}.")
111111

112112
async def logout(self, user: User, refresh_token: str | None) -> None:
113-
raise NotImplementedError("Logout is not supported by DummyAuthProvider")
113+
raise NotImplementedError(f"Logout is not supported by {self.__class__.__name__}.")

syncmaster/server/providers/auth/keycloak_provider.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -52,7 +52,7 @@ async def get_token_password_grant(
5252
client_id: str | None = None,
5353
client_secret: str | None = None,
5454
) -> dict[str, Any]:
55-
raise NotImplementedError("Password grant is not supported by KeycloakAuthProvider.")
55+
raise NotImplementedError(f"Password grant is not supported by {self.__class__.__name__}.")
5656

5757
async def get_token_authorization_code_grant(
5858
self,
Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,88 @@
1+
# SPDX-FileCopyrightText: 2023-2025 MTS PJSC
2+
# SPDX-License-Identifier: Apache-2.0
3+
import logging
4+
from typing import Annotated, Any
5+
6+
from fastapi import Depends, FastAPI, Request
7+
8+
from syncmaster.db.models import User
9+
from syncmaster.exceptions import EntityNotFoundError
10+
from syncmaster.exceptions.auth import AuthorizationError
11+
from syncmaster.server.dependencies import Stub
12+
from syncmaster.server.providers.auth.base_provider import AuthProvider
13+
from syncmaster.server.providers.auth.keycloak_provider import (
14+
KeycloakAuthProvider,
15+
KeycloakOperationError,
16+
)
17+
from syncmaster.server.services.unit_of_work import UnitOfWork
18+
from syncmaster.server.settings.auth.oauth2_gateway import OAuth2GatewayProviderSettings
19+
20+
log = logging.getLogger(__name__)
21+
22+
23+
class OAuth2GatewayProvider(KeycloakAuthProvider):
24+
def __init__(
25+
self,
26+
settings: Annotated[OAuth2GatewayProviderSettings, Depends(Stub(OAuth2GatewayProviderSettings))],
27+
unit_of_work: Annotated[UnitOfWork, Depends()],
28+
) -> None:
29+
super().__init__(settings, unit_of_work) # type: ignore[arg-type]
30+
31+
@classmethod
32+
def setup(cls, app: FastAPI) -> FastAPI:
33+
settings = OAuth2GatewayProviderSettings.model_validate(
34+
app.state.settings.auth.model_dump(exclude={"provider"}),
35+
)
36+
log.info("Using %s provider with settings:\n%s", cls.__name__, settings)
37+
app.dependency_overrides[AuthProvider] = cls
38+
app.dependency_overrides[OAuth2GatewayProviderSettings] = lambda: settings
39+
return app
40+
41+
async def get_current_user(self, access_token: str | None, request: Request) -> User: # noqa: WPS231, WPS217
42+
43+
if not access_token:
44+
log.debug("No access token found in request")
45+
raise AuthorizationError("Missing auth credentials")
46+
47+
try:
48+
token_info = await self.keycloak_openid.a_introspect(access_token)
49+
except KeycloakOperationError as e:
50+
log.info("Failed to introspect token: %s", e)
51+
raise AuthorizationError("Invalid token payload")
52+
53+
if token_info["active"] is False:
54+
raise AuthorizationError("Token is not active")
55+
56+
# these names are hardcoded in keycloak:
57+
# https://github.com/keycloak/keycloak/blob/3ca3a4ad349b4d457f6829eaf2ae05f1e01408be/core/src/main/java/org/keycloak/representations/IDToken.java
58+
# TODO: make sure which fields are guaranteed
59+
login = token_info["preferred_username"]
60+
email = token_info.get("email")
61+
first_name = token_info.get("given_name")
62+
middle_name = token_info.get("middle_name")
63+
last_name = token_info.get("family_name")
64+
65+
async with self._uow:
66+
try:
67+
user = await self._uow.user.read_by_username(login)
68+
except EntityNotFoundError:
69+
user = await self._uow.user.create(
70+
username=login,
71+
email=email,
72+
first_name=first_name,
73+
middle_name=middle_name,
74+
last_name=last_name,
75+
)
76+
return user
77+
78+
async def get_token_authorization_code_grant(
79+
self,
80+
code: str,
81+
scopes: list[str] | None = None,
82+
client_id: str | None = None,
83+
client_secret: str | None = None,
84+
) -> dict[str, Any]:
85+
raise NotImplementedError(f"Authorization code grant is not supported by {self.__class__.__name__}.")
86+
87+
async def logout(self, user: User, refresh_token: str | None) -> None:
88+
raise NotImplementedError(f"Logout is not supported by {self.__class__.__name__}.")
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
# SPDX-FileCopyrightText: 2023-2025 MTS PJSC
2+
# SPDX-License-Identifier: Apache-2.0
3+
from pydantic import BaseModel, Field
4+
5+
from syncmaster.server.settings.auth.keycloak import KeycloakSettings
6+
7+
8+
class OAuth2GatewayProviderSettings(BaseModel):
9+
"""Settings related to Keycloak interaction."""
10+
11+
keycloak: KeycloakSettings = Field(
12+
description="Keycloak settings",
13+
)

tests/test_unit/test_auth/auth_fixtures/__init__.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
from tests.test_unit.test_auth.auth_fixtures.keycloak_fixture import (
22
create_session_cookie,
33
mock_keycloak_api,
4+
mock_keycloak_introspect_token,
45
mock_keycloak_logout,
56
mock_keycloak_realm,
67
mock_keycloak_token_refresh,

tests/test_unit/test_auth/auth_fixtures/keycloak_fixture.py

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -181,3 +181,30 @@ def mock_keycloak_logout(settings, mock_keycloak_api):
181181
logout_url = f"{realm_url}/protocol/openid-connect/logout"
182182

183183
mock_keycloak_api.post(logout_url).respond(status_code=204)
184+
185+
186+
@pytest.fixture
187+
def mock_keycloak_introspect_token(settings, mock_keycloak_api):
188+
def _mock_keycloak_introspect_token(user):
189+
keycloak_settings = settings.auth.model_dump()["keycloak"]
190+
server_url = keycloak_settings["server_url"]
191+
realm_name = keycloak_settings["client_id"]
192+
realm_url = f"{server_url}/realms/{realm_name}"
193+
194+
payload = {
195+
"preferred_username": user.username,
196+
"email": user.email,
197+
"given_name": user.first_name,
198+
"middle_name": user.middle_name,
199+
"family_name": user.last_name,
200+
"active": user.is_active,
201+
}
202+
introspect_url = f"{realm_url}/protocol/openid-connect/token/introspect"
203+
204+
mock_keycloak_api.post(introspect_url).respond(
205+
json=payload,
206+
status_code=200,
207+
content_type="application/json",
208+
)
209+
210+
return _mock_keycloak_introspect_token
Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
import pytest
2+
from httpx import AsyncClient
3+
4+
from syncmaster.server.settings import ServerAppSettings as Settings
5+
from tests.mocks import MockUser
6+
7+
OAuth2GatewayProvider = "syncmaster.server.providers.auth.oauth2_gateway_provider.OAuth2GatewayProvider"
8+
pytestmark = [pytest.mark.asyncio, pytest.mark.server]
9+
10+
11+
@pytest.mark.parametrize(
12+
"settings",
13+
[
14+
{
15+
"auth": {
16+
"provider": OAuth2GatewayProvider,
17+
},
18+
},
19+
],
20+
indirect=True,
21+
)
22+
async def test_get_keycloak_token_active(
23+
client: AsyncClient,
24+
simple_user: MockUser,
25+
settings: Settings,
26+
mock_keycloak_introspect_token,
27+
):
28+
29+
mock_keycloak_introspect_token(simple_user)
30+
31+
headers = {
32+
"Authorization": "Bearer token",
33+
}
34+
response = await client.get(
35+
f"/v1/users/{simple_user.id}",
36+
headers=headers,
37+
)
38+
39+
assert response.status_code == 200, response.json()
40+
assert response.json() == {
41+
"id": simple_user.id,
42+
"is_superuser": simple_user.is_superuser,
43+
"username": simple_user.username,
44+
}
45+
46+
47+
@pytest.mark.parametrize(
48+
"settings",
49+
[
50+
{
51+
"auth": {
52+
"provider": OAuth2GatewayProvider,
53+
},
54+
},
55+
],
56+
indirect=True,
57+
)
58+
async def test_get_keycloak_token_inactive(
59+
client: AsyncClient,
60+
simple_user: MockUser,
61+
inactive_user: MockUser,
62+
settings: Settings,
63+
mock_keycloak_introspect_token,
64+
):
65+
mock_keycloak_introspect_token(inactive_user)
66+
67+
headers = {
68+
"Authorization": "Bearer token",
69+
}
70+
71+
response = await client.get(
72+
f"/v1/users/{simple_user.id}",
73+
headers=headers,
74+
)
75+
assert response.status_code == 401, response.json()
76+
assert response.json() == {"error": {"code": "unauthorized", "details": None, "message": "Not authenticated"}}

0 commit comments

Comments
 (0)