Skip to content

Commit d8ebb59

Browse files
author
maxim-lixakov
committed
[DOP-21268] - implement KeycloakAuthProvider
1 parent c053e6e commit d8ebb59

File tree

12 files changed

+156
-54
lines changed

12 files changed

+156
-54
lines changed

.env.docker

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -43,7 +43,7 @@ SYNCMASTER__AUTH__PROVIDER=syncmaster.backend.providers.auth.keycloak.KeycloakAu
4343
SYNCMASTER__AUTH__KEYCLOAK__TOKEN_URL=http://localhost:8080/auth/realms/fastapi-realm/protocol/openid-connect/token
4444

4545

46-
SYNCMASTER__AUTH__PROVIDER=syncmaster.backend.providers.auth.dummy.DummyAuthProvider
46+
SYNCMASTER__AUTH__PROVIDER=syncmaster.backend.providers.auth.dummy_provider.DummyAuthProvider
4747
SYNCMASTER__AUTH__ACCESS_TOKEN__SECRET_KEY=bae1thahr8Iyaisai0kohvoh1aeg5quu
4848

4949
# RabbitMQ

.env.local

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@ export SYNCMASTER__CRYPTO_KEY=UBgPTioFrtH2unlC4XFDiGf5sYfzbdSf_VgiUSaQc94=
1919
export SYNCMASTER__DATABASE__URL=postgresql+asyncpg://syncmaster:changeme@localhost:5432/syncmaster
2020

2121
# Auth
22-
export SYNCMASTER__AUTH__PROVIDER=syncmaster.backend.providers.auth.dummy.DummyAuthProvider
22+
export SYNCMASTER__AUTH__PROVIDER=syncmaster.backend.providers.auth.dummy_provider.DummyAuthProvider
2323
export SYNCMASTER__AUTH__ACCESS_TOKEN__SECRET_KEY=bae1thahr8Iyaisai0kohvoh1aeg5quu
2424

2525
# RabbitMQ

pyproject.toml

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -183,6 +183,10 @@ ignore_missing_imports = true
183183
module = "pyarrow.*"
184184
ignore_missing_imports = true
185185

186+
[[tool.mypy.overrides]]
187+
module = "keycloak.*"
188+
ignore_missing_imports = true
189+
186190
[[tool.mypy.overrides]]
187191
module = "avro.*"
188192
ignore_missing_imports = true

syncmaster/backend/api/v1/auth.py

Lines changed: 30 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,10 +3,12 @@
33
from typing import Annotated
44

55
from fastapi import APIRouter, Depends
6+
from fastapi.responses import RedirectResponse
67
from fastapi.security import OAuth2PasswordRequestForm
78

89
from syncmaster.backend.dependencies import Stub
910
from syncmaster.backend.providers.auth import AuthProvider
11+
from syncmaster.backend.providers.auth.keycloak_provider import KeycloakAuthProvider
1012
from syncmaster.errors.registration import get_error_responses
1113
from syncmaster.errors.schemas.invalid_request import InvalidRequestSchema
1214
from syncmaster.errors.schemas.not_authorized import NotAuthorizedSchema
@@ -20,7 +22,7 @@
2022

2123

2224
@router.post("/token")
23-
async def login(
25+
async def token(
2426
auth_provider: Annotated[AuthProvider, Depends(Stub(AuthProvider))],
2527
form_data: OAuth2PasswordRequestForm = Depends(),
2628
) -> AuthTokenSchema:
@@ -33,3 +35,30 @@ async def login(
3335
client_secret=form_data.client_secret,
3436
)
3537
return AuthTokenSchema.parse_obj(token)
38+
39+
40+
@router.get("/login")
41+
async def login(
42+
auth_provider: Annotated[AuthProvider, Depends(Stub(AuthProvider))],
43+
):
44+
return RedirectResponse(url="/")
45+
46+
47+
@router.get("/callback")
48+
async def auth_callback(
49+
code: str,
50+
auth_provider: Annotated[KeycloakAuthProvider, Depends(Stub(KeycloakAuthProvider))],
51+
) -> AuthTokenSchema:
52+
token = await auth_provider.get_token(
53+
grant_type="authorization_code",
54+
code=code,
55+
redirect_uri=auth_provider.settings.redirect_uri,
56+
)
57+
access_token = token["access_token"]
58+
# Optionally, save the refresh_token, expires_in, etc.
59+
# Use the access_token to get the current user
60+
await auth_provider.get_current_user(access_token)
61+
# Create a session, set cookies, redirect as needed
62+
response = RedirectResponse(url="/")
63+
response.set_cookie(key="access_token", value=access_token, httponly=True)
64+
return AuthTokenSchema.parse_obj(token)
Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,3 @@
11
# SPDX-FileCopyrightText: 2023-2024 MTS PJSC
22
# SPDX-License-Identifier: Apache-2.0
3-
from syncmaster.backend.providers.auth.base import AuthProvider
3+
from syncmaster.backend.providers.auth.base_provider import AuthProvider

syncmaster/backend/providers/auth/base.py renamed to syncmaster/backend/providers/auth/base_provider.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -77,6 +77,8 @@ async def get_token(
7777
scopes: list[str] | None = None,
7878
client_id: str | None = None,
7979
client_secret: str | None = None,
80+
*args,
81+
**kwargs,
8082
) -> dict[str, Any]:
8183
"""
8284
This method should perform authentication and return JWT token.

syncmaster/backend/providers/auth/dummy.py renamed to syncmaster/backend/providers/auth/dummy_provider.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@
99
from fastapi import Depends, FastAPI
1010

1111
from syncmaster.backend.dependencies import Stub
12-
from syncmaster.backend.providers.auth.base import AuthProvider
12+
from syncmaster.backend.providers.auth.base_provider import AuthProvider
1313
from syncmaster.backend.services import UnitOfWork
1414
from syncmaster.backend.utils.jwt import decode_jwt, sign_jwt
1515
from syncmaster.db.models import User

syncmaster/backend/providers/auth/keycloak.py

Lines changed: 0 additions & 10 deletions
This file was deleted.
Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,102 @@
1+
# SPDX-FileCopyrightText: 2023-2024 MTS PJSC
2+
# SPDX-License-Identifier: Apache-2.0
3+
4+
import logging
5+
from typing import Annotated, Any
6+
7+
from fastapi import Depends, FastAPI
8+
from keycloak import KeycloakOpenID
9+
10+
from syncmaster.backend.dependencies import Stub
11+
from syncmaster.backend.providers.auth.base_provider import AuthProvider
12+
from syncmaster.backend.services import UnitOfWork
13+
from syncmaster.db.models import User
14+
from syncmaster.exceptions import EntityNotFoundError
15+
from syncmaster.exceptions.auth import AuthorizationError
16+
from syncmaster.settings.auth.keycloak import KeycloakAuthProviderSettings
17+
18+
log = logging.getLogger(__name__)
19+
20+
21+
class KeycloakAuthProvider(AuthProvider):
22+
def __init__(
23+
self,
24+
settings: Annotated[KeycloakAuthProviderSettings, Depends(Stub(KeycloakAuthProviderSettings))],
25+
unit_of_work: Annotated[UnitOfWork, Depends()],
26+
) -> None:
27+
self.settings = settings
28+
self._uow = unit_of_work
29+
self.keycloak_openid = KeycloakOpenID(
30+
server_url=self.settings.server_url,
31+
client_id=self.settings.client_id,
32+
realm_name=self.settings.realm_name,
33+
client_secret_key=self.settings.client_secret.get_secret_value(),
34+
verify=self.settings.verify_ssl,
35+
)
36+
self.public_key = (
37+
"-----BEGIN PUBLIC KEY-----\n" + self.keycloak_openid.public_key() + "\n-----END PUBLIC KEY-----"
38+
)
39+
self.options = {
40+
"verify_signature": self.settings.verify_signature,
41+
"verify_aud": self.settings.verify_aud,
42+
"verify_exp": self.settings.verify_exp,
43+
}
44+
45+
@classmethod
46+
def setup(cls, app: FastAPI) -> FastAPI:
47+
settings = KeycloakAuthProviderSettings.parse_obj(app.state.settings.auth.dict(exclude={"provider"}))
48+
log.info("Using %s provider with settings:\n%s", cls.__name__, settings)
49+
app.dependency_overrides[AuthProvider] = cls
50+
app.dependency_overrides[KeycloakAuthProviderSettings] = lambda: settings
51+
return app
52+
53+
async def get_current_user(self, access_token: str) -> User:
54+
if not access_token:
55+
raise AuthorizationError("Missing auth credentials")
56+
try:
57+
token_info = self.keycloak_openid.decode_token(
58+
access_token,
59+
key=self.public_key,
60+
options=self.options,
61+
)
62+
except Exception as e:
63+
raise AuthorizationError("Invalid token") from e
64+
user_id = token_info.get("sub")
65+
login = token_info.get("username")
66+
if not user_id:
67+
raise AuthorizationError("Invalid token payload")
68+
# Fetch user from database using user_id or create new one
69+
async with self._uow:
70+
try:
71+
user = await self._uow.user.read_by_username(login)
72+
except EntityNotFoundError:
73+
# Create new user if not exists
74+
user = await self._uow.user.create(
75+
username=token_info.get("preferred_username"),
76+
is_active=True,
77+
)
78+
if not user.is_active:
79+
raise AuthorizationError(f"User {user.username!r} is disabled")
80+
return user
81+
82+
async def get_token(
83+
self,
84+
grant_type: str | None = None,
85+
code: str | None = None,
86+
redirect_uri: str | None = None,
87+
scopes: list[str] | None = None,
88+
client_id: str | None = None,
89+
client_secret: str | None = None,
90+
) -> dict[str, Any]:
91+
if grant_type != "authorization_code" or not code:
92+
raise AuthorizationError("Invalid grant type or missing code")
93+
try:
94+
redirect_uri = redirect_uri or self.settings.redirect_uri
95+
token = self.keycloak_openid.token(
96+
grant_type=grant_type,
97+
code=code,
98+
redirect_uri=redirect_uri,
99+
)
100+
return token
101+
except Exception as e:
102+
raise AuthorizationError("Failed to get token") from e

syncmaster/settings/auth/__init__.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ class AuthSettings(BaseModel):
1616
1717
.. code-block:: bash
1818
19-
SYNCMASTER__AUTH__PROVIDER=syncmaster.backend.providers.auth.dummy.DummyAuthProvider
19+
SYNCMASTER__AUTH__PROVIDER=syncmaster.backend.providers.auth.dummy_provider.DummyAuthProvider
2020
2121
# pass access_key.secret_key = "secret" to DummyAuthProviderSettings
2222
SYNCMASTER__AUTH__ACCESS_KEY__SECRET_KEY=secret

0 commit comments

Comments
 (0)