Skip to content

Commit 59fa2a8

Browse files
committed
[DOP-23122] Use async methods of Keycloak client
1 parent 162b0a0 commit 59fa2a8

File tree

11 files changed

+131
-106
lines changed

11 files changed

+131
-106
lines changed

poetry.lock

Lines changed: 10 additions & 13 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

pyproject.toml

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -139,7 +139,7 @@ onetl = {extras = ["spark", "s3", "hdfs"], version = "^0.12.0"}
139139
faker = ">=28.4.1,<34.0.0"
140140
coverage = "^7.6.1"
141141
gevent = "^24.2.1"
142-
responses = "*"
142+
respx = "*"
143143

144144
[tool.poetry.group.dev.dependencies]
145145
mypy = "^1.11.2"
@@ -196,6 +196,10 @@ ignore_missing_imports = true
196196
module = "keycloak.*"
197197
ignore_missing_imports = true
198198

199+
[[tool.mypy.overrides]]
200+
module = "jwcrypto.*"
201+
ignore_missing_imports = true
202+
199203
[[tool.mypy.overrides]]
200204
module = "avro.*"
201205
ignore_missing_imports = true

syncmaster/server/providers/auth/base_provider.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -52,7 +52,7 @@ def __init__(
5252
...
5353

5454
@abstractmethod
55-
async def get_current_user(self, access_token: Any, *args, **kwargs) -> User:
55+
async def get_current_user(self, access_token: str | None, **kwargs) -> User:
5656
"""
5757
This method should return currently logged in user.
5858

syncmaster/server/providers/auth/dummy_provider.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -37,7 +37,7 @@ def setup(cls, app: FastAPI) -> FastAPI:
3737
app.dependency_overrides[DummyAuthProviderSettings] = lambda: settings
3838
return app
3939

40-
async def get_current_user(self, access_token: str, *args, **kwargs) -> User:
40+
async def get_current_user(self, access_token: str | None, **kwargs) -> User:
4141
if not access_token:
4242
raise AuthorizationError("Missing auth credentials")
4343

syncmaster/server/providers/auth/keycloak_provider.py

Lines changed: 26 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,13 @@
11
# SPDX-FileCopyrightText: 2023-2024 MTS PJSC
22
# SPDX-License-Identifier: Apache-2.0
33
import logging
4-
from typing import Annotated, Any
4+
from typing import Annotated, Any, NoReturn
55

66
from fastapi import Depends, FastAPI, Request
7-
from keycloak import KeycloakOpenID
7+
from jwcrypto.common import JWException
8+
from keycloak import KeycloakOpenID, KeycloakOperationError
89

10+
from syncmaster.db.models import User
911
from syncmaster.exceptions import EntityNotFoundError
1012
from syncmaster.exceptions.auth import AuthorizationError
1113
from syncmaster.exceptions.redirect import RedirectException
@@ -63,83 +65,75 @@ async def get_token_authorization_code_grant(
6365
) -> dict[str, Any]:
6466
try:
6567
redirect_uri = redirect_uri or self.settings.keycloak.redirect_uri
66-
token = self.keycloak_openid.token(
68+
token = await self.keycloak_openid.a_token(
6769
grant_type="authorization_code",
6870
code=code,
6971
redirect_uri=redirect_uri,
7072
)
7173
return token
72-
except Exception as e:
74+
except KeycloakOperationError as e:
7375
raise AuthorizationError("Failed to get token") from e
7476

75-
async def get_current_user(self, access_token: str, *args, **kwargs) -> Any:
77+
async def get_current_user(self, access_token: str | None, **kwargs) -> User:
7678
request: Request = kwargs["request"]
77-
refresh_token = request.session.get("refresh_token")
78-
7979
if not access_token:
8080
log.debug("No access token found in session.")
81-
self.redirect_to_auth(request.url.path)
81+
await self.redirect_to_auth(request.url.path)
8282

8383
try:
8484
# if user is disabled or blocked in Keycloak after the token is issued, he will
8585
# remain authorized until the token expires (not more than 15 minutes in MTS SSO)
86-
token_info = self.keycloak_openid.decode_token(token=access_token)
87-
except Exception as e:
86+
token_info = await self.keycloak_openid.a_decode_token(token=access_token)
87+
except (KeycloakOperationError, JWException) as e:
8888
log.info("Access token is invalid or expired: %s", e)
89-
token_info = None
89+
token_info = {}
9090

91+
refresh_token = request.session.get("refresh_token")
9192
if not token_info and refresh_token:
9293
log.debug("Access token invalid. Attempting to refresh.")
9394

9495
try:
95-
new_tokens = await self.refresh_access_token(refresh_token)
96+
new_tokens = await self.keycloak_openid.a_refresh_token(refresh_token)
9697

9798
new_access_token = new_tokens.get("access_token")
9899
new_refresh_token = new_tokens.get("refresh_token")
99100
request.session["access_token"] = new_access_token
100101
request.session["refresh_token"] = new_refresh_token
101102

102-
token_info = self.keycloak_openid.decode_token(
103-
token=new_access_token,
104-
)
103+
token_info = await self.keycloak_openid.a_decode_token(token=new_access_token)
105104
log.debug("Access token refreshed and decoded successfully.")
106-
except Exception as e:
105+
except (KeycloakOperationError, JWException) as e:
107106
log.debug("Failed to refresh access token: %s", e)
108-
self.redirect_to_auth(request.url.path)
107+
await self.redirect_to_auth(request.url.path)
109108

110109
# these names are hardcoded in keycloak:
111110
# https://github.com/keycloak/keycloak/blob/3ca3a4ad349b4d457f6829eaf2ae05f1e01408be/core/src/main/java/org/keycloak/representations/IDToken.java
112111
user_id = token_info.get("sub")
112+
if not user_id:
113+
raise AuthorizationError("Invalid token payload")
114+
113115
login = token_info.get("preferred_username")
114116
email = token_info.get("email")
115117
first_name = token_info.get("given_name")
116118
middle_name = token_info.get("middle_name")
117119
last_name = token_info.get("family_name")
118120

119-
if not user_id:
120-
raise AuthorizationError("Invalid token payload")
121-
122-
async with self._uow:
123-
try:
124-
user = await self._uow.user.read_by_username(login)
125-
except EntityNotFoundError:
126-
user = await self._uow.user.create(
121+
try:
122+
return await self._uow.user.read_by_username(login)
123+
except EntityNotFoundError:
124+
async with self._uow:
125+
return await self._uow.user.create(
127126
username=login,
128127
email=email,
129128
first_name=first_name,
130129
middle_name=middle_name,
131130
last_name=last_name,
132131
is_active=True,
133132
)
134-
return user
135-
136-
async def refresh_access_token(self, refresh_token: str) -> dict[str, Any]:
137-
new_tokens = self.keycloak_openid.refresh_token(refresh_token)
138-
return new_tokens
139133

140-
def redirect_to_auth(self, path: str) -> None:
134+
async def redirect_to_auth(self, path: str) -> NoReturn:
141135
state = generate_state(path)
142-
auth_url = self.keycloak_openid.auth_url(
136+
auth_url = await self.keycloak_openid.a_auth_url(
143137
redirect_uri=self.settings.keycloak.redirect_uri,
144138
scope=self.settings.keycloak.scope,
145139
state=state,
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
# SPDX-FileCopyrightText: 2023-2024 MTS PJSC
2+
# SPDX-License-Identifier: Apache-2.0
3+
4+
from pydantic import BaseModel, Field, ImportString
5+
6+
7+
class AuthSettings(BaseModel):
8+
"""Authorization-related settings.
9+
10+
Here you can set auth provider class.
11+
12+
Examples
13+
--------
14+
15+
.. code-block:: bash
16+
17+
SYNCMASTER__AUTH__PROVIDER=syncmaster.server.providers.auth.dummy_provider.DummyAuthProvider
18+
"""
19+
20+
provider: ImportString = Field( # type: ignore[assignment]
21+
default="syncmaster.server.providers.auth.dummy_provider.DummyAuthProvider",
22+
description="Full name of auth provider class",
23+
validate_default=True,
24+
)
25+
26+
class Config:
27+
extra = "allow"

tests/conftest.py

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -131,27 +131,27 @@ async def session(sessionmaker: async_sessionmaker[AsyncSession]):
131131
await session.close()
132132

133133

134-
@pytest.fixture(scope="session")
134+
@pytest.fixture
135135
def mocked_celery() -> Celery:
136136
celery_app = Mock(Celery)
137137
celery_app.send_task = AsyncMock()
138138
return celery_app
139139

140140

141-
@pytest_asyncio.fixture(scope="session")
141+
@pytest_asyncio.fixture
142142
async def app(settings: Settings, mocked_celery: Celery) -> FastAPI:
143143
app = application_factory(settings=settings)
144144
app.dependency_overrides[Celery] = lambda: mocked_celery
145145
return app
146146

147147

148-
@pytest_asyncio.fixture(scope="session")
148+
@pytest_asyncio.fixture
149149
async def client_with_mocked_celery(app: FastAPI) -> AsyncGenerator:
150150
async with AsyncClient(app=app, base_url="http://testserver") as client:
151151
yield client
152152

153153

154-
@pytest_asyncio.fixture(scope="session")
154+
@pytest_asyncio.fixture
155155
async def client(settings: Settings) -> AsyncGenerator:
156156
logger.info("START CLIENT FIXTURE")
157157
app = application_factory(settings=settings)
@@ -160,7 +160,7 @@ async def client(settings: Settings) -> AsyncGenerator:
160160
logger.info("END CLIENT FIXTURE")
161161

162162

163-
@pytest.fixture(scope="session", params=[{}])
163+
@pytest.fixture
164164
def celery(worker_settings: WorkerAppSettings) -> Celery:
165165
celery_app = celery_factory(worker_settings)
166166
return celery_app

tests/test_unit/test_auth/auth_fixtures/__init__.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
from tests.test_unit.test_auth.auth_fixtures.keycloak_fixture import (
22
create_session_cookie,
3+
mock_keycloak_api,
34
mock_keycloak_realm,
45
mock_keycloak_token_refresh,
56
mock_keycloak_well_known,

0 commit comments

Comments
 (0)