Skip to content

Commit d466895

Browse files
author
maxim-lixakov
committed
[DOP-21268] - implement KeycloakAuthProvider
1 parent 15467e5 commit d466895

File tree

19 files changed

+285
-97
lines changed

19 files changed

+285
-97
lines changed

.env.docker

Lines changed: 13 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -18,33 +18,23 @@ SYNCMASTER__CRYPTO_KEY=UBgPTioFrtH2unlC4XFDiGf5sYfzbdSf_VgiUSaQc94=
1818
# Postgres
1919
SYNCMASTER__DATABASE__URL=postgresql+asyncpg://syncmaster:changeme@db:5432/syncmaster
2020

21-
# Keycloack (MTS)
22-
SYNCMASTER__AUTH__KEYCLOAK_SERVER_URL=https://isso.mts.ru/auth/
23-
SYNCMASTER__AUTH__KEYCLOAK_REALM_NAME=mts
24-
SYNCMASTER__AUTH__KEYCLOAK_CLIENT_ID=syncmaster_dev
25-
SYNCMASTER__AUTH__KEYCLOAK_CLIENT_SECRET=secret
26-
SYNCMASTER__AUTH__KEYCLOAK_REDIRECT_URI=http://localhost:8000/callback
27-
SYNCMASTER__AUTH__KEYCLOAK_ADMIN_REDIRECT_URI=http://localhost:8000/admin/callback
28-
SYNCMASTER__AUTH__KEYCLOAK_SCOPE=email
21+
# Keycloack Auth
22+
SYNCMASTER__AUTH__SERVER_URL=http://keycloak:8080/
23+
SYNCMASTER__AUTH__REALM_NAME=fastapi-realm
24+
SYNCMASTER__AUTH__CLIENT_ID=fastapi-client
25+
SYNCMASTER__AUTH__CLIENT_SECRET=VoLrqGz1HGjp6MiwzRaGWIu7z7imKIHb
26+
SYNCMASTER__AUTH__REDIRECT_URI=http://localhost:8000/v1/auth/callback
27+
SYNCMASTER__AUTH__ADMIN_REDIRECT_URI=http://localhost:8000/v1/auth/callback
28+
SYNCMASTER__AUTH__SCOPE=email
2929
SYNCMASTER__AUTH__KEYCLOAK_INTROSPECTION_DELAY=60
30-
SYNCMASTER__AUTH__PROVIDER=syncmaster.backend.providers.auth.keycloak.KeycloakAuthProvider
31-
SYNCMASTER__AUTH__KEYCLOAK_TOKEN_URL=https://isso.mts.ru/auth/realms/mts/protocol/openid-connect/token
30+
SYNCMASTER__AUTH__PROVIDER=syncmaster.backend.providers.auth.keycloak_provider.KeycloakAuthProvider
31+
SYNCMASTER__AUTH__KEYCLOAK_TOKEN_URL=http://keycloak:8080/realms/fastapi-realm/protocol/openid-connect/token
3232

3333

34-
SYNCMASTER__AUTH__KEYCLOAK__SERVER_URL=http://localhost:8080/auth/
35-
SYNCMASTER__AUTH__KEYCLOAK__REALM_NAME=fastapi-realm
36-
SYNCMASTER__AUTH__KEYCLOAK__CLIENT_ID=fastapi-client
37-
SYNCMASTER__AUTH__KEYCLOAK__CLIENT_SECRET=VoLrqGz1HGjp6MiwzRaGWIu7z7imKIHb
38-
SYNCMASTER__AUTH__KEYCLOAK__REDIRECT_URI=http://localhost:8000/callback
39-
SYNCMASTER__AUTH__KEYCLOAK__ADMIN_REDIRECT_URI=http://localhost:8000/admin/callback
40-
SYNCMASTER__AUTH__KEYCLOAK__SCOPE=email
41-
SYNCMASTER__AUTH__KEYCLOAK__INTROSPECTION_DELAY=60
42-
SYNCMASTER__AUTH__PROVIDER=syncmaster.backend.providers.auth.keycloak.KeycloakAuthProvider
43-
SYNCMASTER__AUTH__KEYCLOAK__TOKEN_URL=http://localhost:8080/auth/realms/fastapi-realm/protocol/openid-connect/token
44-
45-
46-
SYNCMASTER__AUTH__PROVIDER=syncmaster.backend.providers.auth.dummy.DummyAuthProvider
34+
# Dummy Auth
35+
SYNCMASTER__AUTH__PROVIDER=syncmaster.backend.providers.auth.dummy_provider.DummyAuthProvider
4736
SYNCMASTER__AUTH__ACCESS_TOKEN__SECRET_KEY=bae1thahr8Iyaisai0kohvoh1aeg5quu
37+
SYNCMASTER__AUTH__PROVIDER=syncmaster.backend.providers.auth.keycloak_provider.KeycloakAuthProvider
4838

4939
# RabbitMQ
5040
SYNCMASTER__BROKER__URL=amqp://guest:guest@rabbitmq:5672/

.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
@@ -185,6 +185,10 @@ ignore_missing_imports = true
185185
module = "pyarrow.*"
186186
ignore_missing_imports = true
187187

188+
[[tool.mypy.overrides]]
189+
module = "keycloak.*"
190+
ignore_missing_imports = true
191+
188192
[[tool.mypy.overrides]]
189193
module = "avro.*"
190194
ignore_missing_imports = true

syncmaster/backend/api/v1/auth.py

Lines changed: 35 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,25 @@
11
# SPDX-FileCopyrightText: 2023-2024 MTS PJSC
22
# SPDX-License-Identifier: Apache-2.0
3-
from typing import Annotated
3+
from typing import TYPE_CHECKING, Annotated
44

5-
from fastapi import APIRouter, Depends
5+
from fastapi import APIRouter, Depends, HTTPException, status
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.utils.state import validate_state
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
1315
from syncmaster.schemas.v1.auth import AuthTokenSchema
1416

17+
if TYPE_CHECKING:
18+
from syncmaster.backend.providers.auth import (
19+
DummyAuthProvider,
20+
KeycloakAuthProvider,
21+
)
22+
1523
router = APIRouter(
1624
prefix="/auth",
1725
tags=["Auth"],
@@ -20,11 +28,11 @@
2028

2129

2230
@router.post("/token")
23-
async def login(
24-
auth_provider: Annotated[AuthProvider, Depends(Stub(AuthProvider))],
31+
async def token(
32+
auth_provider: Annotated["DummyAuthProvider", Depends(Stub(AuthProvider))],
2533
form_data: OAuth2PasswordRequestForm = Depends(),
2634
) -> AuthTokenSchema:
27-
token = await auth_provider.get_token(
35+
token = await auth_provider.get_token_password_grant(
2836
grant_type=form_data.grant_type,
2937
login=form_data.username,
3038
password=form_data.password,
@@ -33,3 +41,25 @@ async def login(
3341
client_secret=form_data.client_secret,
3442
)
3543
return AuthTokenSchema.parse_obj(token)
44+
45+
46+
@router.get("/callback")
47+
async def auth_callback(
48+
code: str,
49+
state: str,
50+
auth_provider: Annotated["KeycloakAuthProvider", Depends(Stub(AuthProvider))],
51+
):
52+
if not validate_state(state):
53+
raise HTTPException(
54+
status_code=status.HTTP_400_BAD_REQUEST,
55+
detail="Invalid state parameter",
56+
)
57+
token = await auth_provider.get_token_authorization_code_grant(
58+
code=code,
59+
redirect_uri=auth_provider.settings.redirect_uri,
60+
)
61+
access_token = token["access_token"]
62+
await auth_provider.get_current_user(access_token)
63+
response = RedirectResponse(url="/")
64+
response.set_cookie(key="access_token", value=access_token, httponly=True)
65+
return AuthTokenSchema.parse_obj(token)
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
# SPDX-FileCopyrightText: 2023-2024 MTS PJSC
22
# SPDX-License-Identifier: Apache-2.0
33

4+
from syncmaster.backend.dependencies.get_access_token import get_access_token
45
from syncmaster.backend.dependencies.stub import Stub
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
# SPDX-FileCopyrightText: 2023-2024 MTS PJSC
2+
# SPDX-License-Identifier: Apache-2.0
3+
from fastapi import Request
4+
from fastapi.security.utils import get_authorization_scheme_param
5+
6+
7+
async def get_access_token(request: Request):
8+
authorization = request.headers.get("Authorization")
9+
scheme, token = get_authorization_scheme_param(authorization)
10+
if not authorization or scheme.lower() != "bearer":
11+
return None
12+
return token

syncmaster/backend/handler.py

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,11 +5,13 @@
55

66
from fastapi import HTTPException, Request, Response, status
77
from fastapi.exceptions import RequestValidationError
8+
from fastapi.responses import RedirectResponse
89
from pydantic import ValidationError
910

1011
from syncmaster.errors.base import APIErrorSchema, BaseErrorSchema
1112
from syncmaster.errors.registration import get_response_for_exception
1213
from syncmaster.exceptions import ActionNotAllowedError, SyncmasterError
14+
from syncmaster.exceptions.auth import AuthorizationError
1315
from syncmaster.exceptions.connection import (
1416
ConnectionDeleteError,
1517
ConnectionNotFoundError,
@@ -31,6 +33,7 @@
3133
QueueDeleteError,
3234
QueueNotFoundError,
3335
)
36+
from syncmaster.exceptions.redirect import RedirectException
3437
from syncmaster.exceptions.run import (
3538
CannotConnectToTaskQueueError,
3639
CannotStopRunError,
@@ -119,6 +122,14 @@ async def syncmsater_exception_handler(request: Request, exc: SyncmasterError):
119122
content=content,
120123
)
121124

125+
if isinstance(exc, RedirectException):
126+
return RedirectResponse(url=exc.redirect_url)
127+
128+
if isinstance(exc, AuthorizationError):
129+
content.code = "unauthorized"
130+
content.message = "Not authenticated"
131+
return exception_json_response(status=status.HTTP_401_UNAUTHORIZED, content=content)
132+
122133
if isinstance(exc, ConnectionDeleteError):
123134
content.code = "conflict"
124135
return exception_json_response(status=status.HTTP_409_CONFLICT, content=content)

syncmaster/backend/middlewares/cors.py

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,8 +11,15 @@ def apply_cors_middleware(app: FastAPI, settings: CORSSettings) -> FastAPI:
1111
if not settings:
1212
return app
1313

14+
origins = [
15+
"http://localhost:3000", # React app
16+
"http://127.0.0.1:3000", # Alternative localhost
17+
]
1418
app.add_middleware(
1519
CORSMiddleware,
16-
**settings.dict(exclude={"enabled"}),
20+
allow_origins=origins, # Allow requests from the specified origins
21+
allow_credentials=True,
22+
allow_methods=["*"], # Allow all HTTP methods (GET, POST, etc.)
23+
allow_headers=["*"], # Allow all headers
1724
)
1825
return app
Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
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
4+
from syncmaster.backend.providers.auth.dummy_provider import DummyAuthProvider
5+
from syncmaster.backend.providers.auth.keycloak_provider import KeycloakAuthProvider

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

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -69,7 +69,7 @@ async def get_current_user(self, access_token: str) -> User:
6969
...
7070

7171
@abstractmethod
72-
async def get_token(
72+
async def get_token_password_grant(
7373
self,
7474
grant_type: str | None = None,
7575
login: str | None = None,
@@ -99,3 +99,16 @@ async def get_token(
9999
}
100100
"""
101101
...
102+
103+
@abstractmethod
104+
async def get_token_authorization_code_grant(
105+
self,
106+
code: str,
107+
redirect_uri: str,
108+
scopes: list[str] | None = None,
109+
client_id: str | None = None,
110+
client_secret: str | None = None,
111+
) -> dict[str, Any]:
112+
"""
113+
Obtain a token using the Authorization Code grant.
114+
"""

0 commit comments

Comments
 (0)