Skip to content

Commit 02bdf28

Browse files
committed
[DOP-29772] Implement GET /v1/auth/logout endpoint
1 parent f5af023 commit 02bdf28

File tree

11 files changed

+200
-86
lines changed

11 files changed

+200
-86
lines changed
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
Implement ``GET /v1/auth/logout`` endpoint.

poetry.lock

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

pyproject.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -145,6 +145,7 @@ black = "^25.1.0"
145145
flake8 = "^7.2.0"
146146
flake8-pyproject = "^1.2.3"
147147
sqlalchemy = {extras = ["mypy"], version = "^2.0.40"}
148+
types-jwcrypto = "^1.5.0"
148149

149150
[tool.poetry.group.docs.dependencies]
150151
autodoc-pydantic = "^2.2.0"

syncmaster/exceptions/auth.py

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,3 +28,19 @@ def message(self) -> str:
2828
@property
2929
def details(self) -> Any:
3030
return self._details
31+
32+
33+
class LogoutError(SyncmasterError):
34+
"""Error on logout request"""
35+
36+
def __init__(self, details: str) -> None:
37+
self._message = "Logout error"
38+
self._details = details
39+
40+
@property
41+
def message(self) -> str:
42+
return self._message
43+
44+
@property
45+
def details(self) -> Any:
46+
return self._details

syncmaster/server/api/v1/auth.py

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
from fastapi import APIRouter, Depends, Request, Response
77
from fastapi.security import OAuth2PasswordRequestForm
88

9+
from syncmaster.db.models import User
910
from syncmaster.errors.registration import get_error_responses
1011
from syncmaster.errors.schemas.invalid_request import InvalidRequestSchema
1112
from syncmaster.errors.schemas.not_authorized import NotAuthorizedSchema
@@ -16,6 +17,7 @@
1617
DummyAuthProvider,
1718
KeycloakAuthProvider,
1819
)
20+
from syncmaster.server.services.get_user import get_user
1921

2022
router = APIRouter(
2123
prefix="/auth",
@@ -48,9 +50,23 @@ async def auth_callback(
4850
):
4951
token = await auth_provider.get_token_authorization_code_grant(
5052
code=code,
51-
redirect_uri=auth_provider.settings.keycloak.redirect_uri,
5253
)
5354
request.session["access_token"] = token["access_token"]
5455
request.session["refresh_token"] = token["refresh_token"]
56+
return Response(status_code=NO_CONTENT)
57+
5558

59+
@router.get(
60+
"/logout",
61+
summary="Logout user",
62+
status_code=NO_CONTENT,
63+
)
64+
async def logout(
65+
request: Request,
66+
current_user: Annotated[User, Depends(get_user(is_active=True))],
67+
auth_provider: Annotated[KeycloakAuthProvider, Depends(Stub(AuthProvider))],
68+
):
69+
refresh_token = request.session.get("refresh_token", None)
70+
request.session.clear()
71+
await auth_provider.logout(user=current_user, refresh_token=refresh_token)
5672
return Response(status_code=NO_CONTENT)

syncmaster/server/providers/auth/base_provider.py

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
from abc import ABC, abstractmethod
55
from typing import Any
66

7-
from fastapi import FastAPI
7+
from fastapi import FastAPI, Request
88

99
from syncmaster.db.models import User
1010

@@ -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: Any, request: Request) -> User:
5656
"""
5757
This method should return currently logged in user.
5858
@@ -104,11 +104,16 @@ async def get_token_password_grant(
104104
async def get_token_authorization_code_grant(
105105
self,
106106
code: str,
107-
redirect_uri: str,
108107
scopes: list[str] | None = None,
109108
client_id: str | None = None,
110109
client_secret: str | None = None,
111110
) -> dict[str, Any]:
112111
"""
113112
Obtain a token using the Authorization Code grant.
114113
"""
114+
115+
@abstractmethod
116+
async def logout(self, user: User, refresh_token: str | None) -> None:
117+
"""
118+
Logout user
119+
"""

syncmaster/server/providers/auth/dummy_provider.py

Lines changed: 13 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@
2020
log = logging.getLogger(__name__)
2121

2222

23-
class DummyAuthProvider(AuthProvider):
23+
class DummyAuthProvider(AuthProvider): # noqa: WPS338
2424
def __init__(
2525
self,
2626
settings: Annotated[DummyAuthProviderSettings, Depends(Stub(DummyAuthProviderSettings))],
@@ -76,16 +76,6 @@ async def get_token_password_grant(
7676
"expires_at": expires_at,
7777
}
7878

79-
async def get_token_authorization_code_grant(
80-
self,
81-
code: str,
82-
redirect_uri: str,
83-
scopes: list[str] | None = None,
84-
client_id: str | None = None,
85-
client_secret: str | None = None,
86-
) -> dict[str, Any]:
87-
raise NotImplementedError("Authorization code grant is not supported by DummyAuthProvider.")
88-
8979
def _generate_access_token(self, user_id: int) -> tuple[str, float]:
9080
expires_at = time() + self._settings.access_token.expire_seconds
9181
payload = {
@@ -109,3 +99,15 @@ def _get_user_id_from_token(self, token: str) -> int:
10999
return int(payload["user_id"])
110100
except (KeyError, TypeError, ValueError) as e:
111101
raise AuthorizationError("Invalid token") from e
102+
103+
async def get_token_authorization_code_grant(
104+
self,
105+
code: str,
106+
scopes: list[str] | None = None,
107+
client_id: str | None = None,
108+
client_secret: str | None = None,
109+
) -> dict[str, Any]:
110+
raise NotImplementedError("Authorization code grant is not supported by DummyAuthProvider")
111+
112+
async def logout(self, user: User, refresh_token: str | None) -> None:
113+
raise NotImplementedError("Logout is not supported by DummyAuthProvider")

syncmaster/server/providers/auth/keycloak_provider.py

Lines changed: 26 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -4,10 +4,12 @@
44
from typing import Annotated, Any
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.user import User
911
from syncmaster.exceptions import EntityNotFoundError
10-
from syncmaster.exceptions.auth import AuthorizationError
12+
from syncmaster.exceptions.auth import AuthorizationError, LogoutError
1113
from syncmaster.exceptions.redirect import RedirectException
1214
from syncmaster.server.dependencies import Stub
1315
from syncmaster.server.providers.auth.base_provider import AuthProvider
@@ -55,43 +57,39 @@ async def get_token_password_grant(
5557
async def get_token_authorization_code_grant(
5658
self,
5759
code: str,
58-
redirect_uri: str,
5960
scopes: list[str] | None = None,
6061
client_id: str | None = None,
6162
client_secret: str | None = None,
6263
) -> dict[str, Any]:
6364
try:
64-
redirect_uri = redirect_uri or self.settings.keycloak.redirect_uri
6565
token = self.keycloak_openid.token(
6666
grant_type="authorization_code",
6767
code=code,
68-
redirect_uri=redirect_uri,
68+
redirect_uri=self.settings.keycloak.redirect_uri,
6969
)
7070
return token
71-
except Exception as e:
71+
except KeycloakOperationError as e:
7272
raise AuthorizationError("Failed to get token") from e
7373

74-
async def get_current_user(self, access_token: str, *args, **kwargs) -> Any: # noqa: WPS231
75-
request: Request = kwargs["request"]
76-
refresh_token = request.session.get("refresh_token")
77-
74+
async def get_current_user(self, access_token: str, request: Request) -> User: # noqa: WPS231
7875
if not access_token:
7976
log.debug("No access token found in session.")
80-
self.redirect_to_auth(request.url.path)
77+
self.redirect_to_auth()
8178

79+
refresh_token = request.session.get("refresh_token")
8280
try:
8381
# if user is disabled or blocked in Keycloak after the token is issued, he will
8482
# remain authorized until the token expires (not more than 15 minutes in MTS SSO)
8583
token_info = self.keycloak_openid.decode_token(token=access_token)
86-
except Exception as e:
84+
except (KeycloakOperationError, JWException) as e:
8785
log.info("Access token is invalid or expired: %s", e)
8886
token_info = None
8987

9088
if not token_info and refresh_token:
9189
log.debug("Access token invalid. Attempting to refresh.")
9290

9391
try:
94-
new_tokens = await self.refresh_access_token(refresh_token)
92+
new_tokens = self.keycloak_openid.refresh_token(refresh_token)
9593

9694
new_access_token = new_tokens["access_token"]
9795
new_refresh_token = new_tokens["refresh_token"]
@@ -102,9 +100,9 @@ async def get_current_user(self, access_token: str, *args, **kwargs) -> Any: #
102100
token=new_access_token,
103101
)
104102
log.debug("Access token refreshed and decoded successfully.")
105-
except Exception as e:
103+
except (KeycloakOperationError, JWException) as e:
106104
log.debug("Failed to refresh access token: %s", e)
107-
self.redirect_to_auth(request.url.path)
105+
self.redirect_to_auth()
108106

109107
if not token_info:
110108
raise AuthorizationError("Invalid token payload")
@@ -131,13 +129,21 @@ async def get_current_user(self, access_token: str, *args, **kwargs) -> Any: #
131129
)
132130
return user
133131

134-
async def refresh_access_token(self, refresh_token: str) -> dict[str, Any]:
135-
new_tokens = self.keycloak_openid.refresh_token(refresh_token)
136-
return new_tokens
137-
138-
def redirect_to_auth(self, path: str) -> None:
132+
def redirect_to_auth(self) -> None:
139133
auth_url = self.keycloak_openid.auth_url(
140134
redirect_uri=self.settings.keycloak.redirect_uri,
141135
scope=self.settings.keycloak.scope,
142136
)
143137
raise RedirectException(redirect_url=auth_url)
138+
139+
async def logout(self, user: User, refresh_token: str | None) -> None:
140+
if not refresh_token:
141+
log.debug("No refresh token found in session.")
142+
return
143+
144+
try:
145+
self.keycloak_openid.logout(refresh_token)
146+
except KeycloakOperationError as err:
147+
msg = f"Can't logout user: {user.username}"
148+
log.debug("%s. Error: %s", msg, err)
149+
raise LogoutError(msg) from err
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,17 @@
11
from tests.test_unit.test_auth.auth_fixtures.keycloak_fixture import (
22
create_session_cookie,
3+
mock_keycloak_logout,
34
mock_keycloak_realm,
45
mock_keycloak_token_refresh,
56
mock_keycloak_well_known,
67
rsa_keys,
78
)
9+
10+
__all__ = [
11+
"create_session_cookie",
12+
"mock_keycloak_logout",
13+
"mock_keycloak_realm",
14+
"mock_keycloak_token_refresh",
15+
"mock_keycloak_well_known",
16+
"rsa_keys",
17+
]

0 commit comments

Comments
 (0)