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
1 change: 1 addition & 0 deletions docs/changelog/next_release/177.bugfix.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Replace sync methods of Keycloak client with async ones. Previously interaction with Keycloak could block asyncio event loop.
27 changes: 11 additions & 16 deletions poetry.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -135,7 +135,7 @@ pytest-lazy-fixtures = "^1.1.1"
faker = "^37.4.0"
coverage = "^7.9.1"
gevent = ">=24.11.1,<26.0.0"
responses = "^0.25.7"
respx = "^0.22.0"
dirty-equals = "^0.9.0"

[tool.poetry.group.dev.dependencies]
Expand Down
2 changes: 1 addition & 1 deletion syncmaster/server/providers/auth/base_provider.py
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,7 @@ def __init__(
...

@abstractmethod
async def get_current_user(self, access_token: Any, request: Request) -> User:
async def get_current_user(self, access_token: str | None, request: Request) -> User:
"""
This method should return currently logged in user.

Expand Down
2 changes: 1 addition & 1 deletion syncmaster/server/providers/auth/dummy_provider.py
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@ def setup(cls, app: FastAPI) -> FastAPI:
app.dependency_overrides[DummyAuthProviderSettings] = lambda: settings
return app

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

Expand Down
25 changes: 12 additions & 13 deletions syncmaster/server/providers/auth/keycloak_provider.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
# SPDX-FileCopyrightText: 2023-2024 MTS PJSC
# SPDX-License-Identifier: Apache-2.0
import logging
from typing import Annotated, Any
from typing import Annotated, Any, NoReturn

from fastapi import Depends, FastAPI, Request
from jwcrypto.common import JWException
Expand Down Expand Up @@ -62,25 +62,24 @@ async def get_token_authorization_code_grant(
client_secret: str | None = None,
) -> dict[str, Any]:
try:
token = self.keycloak_openid.token(
return await self.keycloak_openid.a_token(
grant_type="authorization_code",
code=code,
redirect_uri=self.settings.keycloak.redirect_uri,
)
return token
except KeycloakOperationError as e:
raise AuthorizationError("Failed to get token") from e

async def get_current_user(self, access_token: str, request: Request) -> User: # noqa: WPS231
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 session.")
self.redirect_to_auth()
log.debug("No access token found in session")
await self.redirect_to_auth()

refresh_token = request.session.get("refresh_token")
try:
# if user is disabled or blocked in Keycloak after the token is issued, he will
# remain authorized until the token expires (not more than 15 minutes in MTS SSO)
token_info = self.keycloak_openid.decode_token(token=access_token)
token_info = await self.keycloak_openid.a_decode_token(access_token)
except (KeycloakOperationError, JWException) as e:
log.info("Access token is invalid or expired: %s", e)
token_info = None
Expand All @@ -89,20 +88,20 @@ async def get_current_user(self, access_token: str, request: Request) -> User:
log.debug("Access token invalid. Attempting to refresh.")

try:
new_tokens = self.keycloak_openid.refresh_token(refresh_token)
new_tokens = await self.keycloak_openid.a_refresh_token(refresh_token)

new_access_token = new_tokens["access_token"]
new_refresh_token = new_tokens["refresh_token"]
request.session["access_token"] = new_access_token
request.session["refresh_token"] = new_refresh_token

token_info = self.keycloak_openid.decode_token(
token_info = await self.keycloak_openid.a_decode_token(
token=new_access_token,
)
log.debug("Access token refreshed and decoded successfully.")
except (KeycloakOperationError, JWException) as e:
log.debug("Failed to refresh access token: %s", e)
self.redirect_to_auth()
await self.redirect_to_auth()

if not token_info:
raise AuthorizationError("Invalid token payload")
Expand All @@ -129,8 +128,8 @@ async def get_current_user(self, access_token: str, request: Request) -> User:
)
return user

def redirect_to_auth(self) -> None:
auth_url = self.keycloak_openid.auth_url(
async def redirect_to_auth(self) -> NoReturn:
auth_url = await self.keycloak_openid.a_auth_url(
redirect_uri=self.settings.keycloak.redirect_uri,
scope=self.settings.keycloak.scope,
)
Expand All @@ -142,7 +141,7 @@ async def logout(self, user: User, refresh_token: str | None) -> None:
return

try:
self.keycloak_openid.logout(refresh_token)
await self.keycloak_openid.a_logout(refresh_token)
except KeycloakOperationError as err:
msg = f"Can't logout user: {user.username}"
log.debug("%s. Error: %s", msg, err)
Expand Down
2 changes: 2 additions & 0 deletions tests/test_unit/test_auth/auth_fixtures/__init__.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
from tests.test_unit.test_auth.auth_fixtures.keycloak_fixture import (
create_session_cookie,
mock_keycloak_api,
mock_keycloak_logout,
mock_keycloak_realm,
mock_keycloak_token_refresh,
Expand All @@ -9,6 +10,7 @@

__all__ = [
"create_session_cookie",
"mock_keycloak_api",
"mock_keycloak_logout",
"mock_keycloak_realm",
"mock_keycloak_token_refresh",
Expand Down
44 changes: 22 additions & 22 deletions tests/test_unit/test_auth/auth_fixtures/keycloak_fixture.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,8 @@

import jwt
import pytest
import responses
import pytest_asyncio
import respx
from cryptography.hazmat.primitives.asymmetric import rsa
from cryptography.hazmat.primitives.serialization import (
Encoding,
Expand Down Expand Up @@ -83,18 +84,25 @@ def _create_session_cookie(user, expire_in_msec=60000) -> str:
return _create_session_cookie


@pytest_asyncio.fixture
async def mock_keycloak_api(settings): # noqa: F811
keycloak_settings = settings.auth.model_dump()["keycloak"]
server_url = keycloak_settings["server_url"]

async with respx.mock(base_url=server_url, assert_all_called=False) as respx_mock:
yield respx_mock


@pytest.fixture
def mock_keycloak_well_known(settings):
def mock_keycloak_well_known(settings, mock_keycloak_api):
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}"
well_known_url = f"{realm_url}/.well-known/openid-configuration"
openid_url = f"{realm_url}/protocol/openid-connect"

responses.add(
responses.GET,
well_known_url,
mock_keycloak_api.get(well_known_url).respond(
json={
"authorization_endpoint": f"{openid_url}/auth",
"token_endpoint": f"{openid_url}/token",
Expand All @@ -103,35 +111,33 @@ def mock_keycloak_well_known(settings):
"jwks_uri": f"{openid_url}/certs",
"issuer": realm_url,
},
status=200,
status_code=200,
content_type="application/json",
)


@pytest.fixture
def mock_keycloak_realm(settings, rsa_keys):
def mock_keycloak_realm(settings, rsa_keys, mock_keycloak_api):
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}"
public_pem_str = get_public_key_pem(rsa_keys["public_key"])

responses.add(
responses.GET,
realm_url,
mock_keycloak_api.get(realm_url).respond(
json={
"realm": realm_name,
"public_key": public_pem_str,
"token-service": f"{realm_url}/protocol/openid-connect/token",
"account-service": f"{realm_url}/account",
},
status=200,
status_code=200,
content_type="application/json",
)


@pytest.fixture
def mock_keycloak_token_refresh(settings, rsa_keys):
def mock_keycloak_token_refresh(settings, rsa_keys, mock_keycloak_api):
keycloak_settings = settings.auth.model_dump()["keycloak"]
server_url = keycloak_settings["server_url"]
realm_name = keycloak_settings["client_id"]
Expand All @@ -154,30 +160,24 @@ def mock_keycloak_token_refresh(settings, rsa_keys):
new_access_token = jwt.encode(payload, private_pem, algorithm="RS256")
new_refresh_token = "mock_new_refresh_token"

responses.add(
responses.POST,
token_url,
mock_keycloak_api.post(token_url).respond(
json={
"access_token": new_access_token,
"refresh_token": new_refresh_token,
"token_type": "bearer",
"expires_in": expires_in,
},
status=200,
status_code=200,
content_type="application/json",
)


@pytest.fixture
def mock_keycloak_logout(settings):
def mock_keycloak_logout(settings, mock_keycloak_api):
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}"
logout_url = f"{realm_url}/protocol/openid-connect/logout"

responses.add(
responses.POST,
logout_url,
status=204,
)
mock_keycloak_api.post(logout_url).respond(status_code=204)
7 changes: 0 additions & 7 deletions tests/test_unit/test_auth/test_auth_keycloak.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
import logging

import pytest
import responses
from dirty_equals import IsStr
from httpx import AsyncClient

Expand All @@ -12,7 +11,6 @@
pytestmark = [pytest.mark.asyncio, pytest.mark.server]


@responses.activate
@pytest.mark.parametrize(
"settings",
[
Expand Down Expand Up @@ -44,7 +42,6 @@ async def test_keycloak_get_user_unauthorized(
}


@responses.activate
@pytest.mark.flaky
@pytest.mark.parametrize(
"settings",
Expand Down Expand Up @@ -81,7 +78,6 @@ async def test_keycloak_get_user_authorized(
}


@responses.activate
@pytest.mark.parametrize(
"settings",
[
Expand Down Expand Up @@ -120,7 +116,6 @@ async def test_keycloak_get_user_expired_access_token(
}


@responses.activate
@pytest.mark.parametrize(
"settings",
[
Expand Down Expand Up @@ -153,7 +148,6 @@ async def test_keycloak_get_user_inactive(
}


@responses.activate
@pytest.mark.parametrize(
"settings",
[
Expand Down Expand Up @@ -184,7 +178,6 @@ async def test_keycloak_auth_callback(
assert response.status_code == 204, response.text


@responses.activate
@pytest.mark.parametrize(
"settings",
[
Expand Down
Loading