Skip to content

Commit b7c623d

Browse files
author
Marat Akhmetov
committed
[DOP-29537] added OAuth2GatewayProvider
1 parent 97f49c1 commit b7c623d

File tree

7 files changed

+178
-8
lines changed

7 files changed

+178
-8
lines changed

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: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
# SPDX-FileCopyrightText: 2023-2024 MTS PJSC
2+
# SPDX-License-Identifier: Apache-2.0
3+
import logging
4+
from typing import Any
5+
6+
from fastapi import 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.providers.auth.keycloak_provider import (
12+
KeycloakAuthProvider,
13+
KeycloakOperationError,
14+
)
15+
16+
log = logging.getLogger(__name__)
17+
18+
19+
class OAuth2GatewayProvider(KeycloakAuthProvider):
20+
async def get_current_user(self, access_token: str | None, request: Request) -> User: # noqa: WPS231, WPS217
21+
22+
if not access_token:
23+
log.debug("No access token found in request")
24+
raise AuthorizationError("Missing auth credentials")
25+
26+
try:
27+
token_info = await self.keycloak_openid.a_introspect(access_token)
28+
except KeycloakOperationError as e:
29+
log.info("Failed to introspect token: %s", e)
30+
raise AuthorizationError("Invalid token payload")
31+
32+
if token_info["active"] is False:
33+
raise AuthorizationError("Token is not active")
34+
35+
# these names are hardcoded in keycloak:
36+
# https://github.com/keycloak/keycloak/blob/3ca3a4ad349b4d457f6829eaf2ae05f1e01408be/core/src/main/java/org/keycloak/representations/IDToken.java
37+
# TODO: make sure which fields are guaranteed
38+
login = token_info["preferred_username"]
39+
email = token_info.get("email")
40+
first_name = token_info.get("given_name")
41+
middle_name = token_info.get("middle_name")
42+
last_name = token_info.get("family_name")
43+
44+
async with self._uow:
45+
try:
46+
user = await self._uow.user.read_by_username(login)
47+
except EntityNotFoundError:
48+
user = await self._uow.user.create(
49+
username=login,
50+
email=email,
51+
first_name=first_name,
52+
middle_name=middle_name,
53+
last_name=last_name,
54+
)
55+
return user
56+
57+
async def get_token_authorization_code_grant(
58+
self,
59+
code: str,
60+
scopes: list[str] | None = None,
61+
client_id: str | None = None,
62+
client_secret: str | None = None,
63+
) -> dict[str, Any]:
64+
raise NotImplementedError(f"Authorization code grant is not supported by {self.__class__.__name__}.")
65+
66+
async def logout(self, user: User, refresh_token: str | None) -> None:
67+
raise NotImplementedError(f"Logout is not supported by {self.__class__.__name__}.")

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)