Skip to content

Commit cada1f1

Browse files
authored
[DOP-21576] Update Keycloak provider for auth with Frontend (#131)
* [DOP-21576] Update Keycloak provider for auth with Frontend * [DOP-21576] Move /auth/me -> /users/me; Remove state from redirect * [DOP-21576] Small fixes * [DOP-21576] remove logout * [DOP-21576] Fix interaction schema * [DOP-21576] Update schema
1 parent af8126f commit cada1f1

File tree

10 files changed

+180
-97
lines changed

10 files changed

+180
-97
lines changed

.env.local

Lines changed: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -16,15 +16,24 @@ export DATA_RENTGEN__UI__API_BROWSER_URL=http://localhost:8000
1616
export DATA_RENTGEN__SERVER__SESSION__SECRET_KEY=session_secret_key
1717

1818
# Keycloak Auth
19-
export DATA_RENTGEN__AUTH__KEYCLOAK__SERVER_URL=http://keycloak:8080
19+
export DATA_RENTGEN__AUTH__KEYCLOAK__SERVER_URL=http://localhost:8080
2020
export DATA_RENTGEN__AUTH__KEYCLOAK__REALM_NAME=manually_created
2121
export DATA_RENTGEN__AUTH__KEYCLOAK__CLIENT_ID=manually_created
22-
export DATA_RENTGEN__AUTH__KEYCLOAK__CLIENT_SECRET=generated_by_keycloak
23-
export DATA_RENTGEN__AUTH__KEYCLOAK__REDIRECT_URI=http://localhost:8000/auth/callback
22+
export DATA_RENTGEN__AUTH__KEYCLOAK__CLIENT_SECRET=change_me
23+
export DATA_RENTGEN__AUTH__KEYCLOAK__REDIRECT_URI=http://localhost:3000/auth/callback
2424
export DATA_RENTGEN__AUTH__KEYCLOAK__SCOPE=email
2525
export DATA_RENTGEN__AUTH__KEYCLOAK__VERIFY_SSL=False
2626
export DATA_RENTGEN__AUTH__PROVIDER=data_rentgen.server.providers.auth.keycloak_provider.KeycloakAuthProvider
2727

2828
# Dummy Auth
2929
export DATA_RENTGEN__AUTH__PROVIDER=data_rentgen.server.providers.auth.dummy_provider.DummyAuthProvider
3030
export DATA_RENTGEN__AUTH__ACCESS_TOKEN__SECRET_KEY=secret
31+
32+
# Cors
33+
export DATA_RENTGEN__SERVER__CORS__ENABLED=True
34+
export DATA_RENTGEN__SERVER__CORS__ALLOW_ORIGINS=["http://localhost:3000"]
35+
export DATA_RENTGEN__SERVER__CORS__ALLOW_CREDENTIALS=true
36+
export DATA_RENTGEN__SERVER__CORS__ALLOW_METHODS=["*"]
37+
export DATA_RENTGEN__SERVER__CORS__ALLOW_HEADERS=["*"]
38+
export DATA_RENTGEN__SERVER__CORS__EXPOSE_HEADERS=["X-Request-ID", "Location", "Access-Control-Allow-Credentials"]
39+

data_rentgen/server/api/handlers.py

Lines changed: 15 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,6 @@
88
from asgi_correlation_id import correlation_id
99
from fastapi import FastAPI, HTTPException, Request, Response
1010
from fastapi.exceptions import RequestValidationError
11-
from fastapi.responses import RedirectResponse
1211
from pydantic import ValidationError
1312

1413
from data_rentgen.exceptions import ApplicationError, AuthorizationError, RedirectError
@@ -92,8 +91,20 @@ def application_exception_handler(request: Request, exc: ApplicationError) -> Re
9291
)
9392

9493

95-
def redirect_exception_handler(_: Request, exc: RedirectError) -> Response:
96-
return RedirectResponse(url=exc.message)
94+
def not_authorized_redirect_exception_handler(request: Request, exc: RedirectError) -> Response:
95+
logger.info("Redirect user to keycloak")
96+
response = get_response_for_exception(RedirectError)
97+
if not response:
98+
return unknown_exception_handler(request, exc)
99+
content = response.schema( # type: ignore[call-arg]
100+
message=exc.message,
101+
details=exc.details,
102+
)
103+
104+
return exception_json_response(
105+
status=response.status,
106+
content=content,
107+
)
97108

98109

99110
def exception_json_response(
@@ -112,7 +123,7 @@ def exception_json_response(
112123

113124

114125
def apply_exception_handlers(app: FastAPI) -> None:
115-
app.add_exception_handler(RedirectError, redirect_exception_handler) # type: ignore[arg-type]
126+
app.add_exception_handler(RedirectError, not_authorized_redirect_exception_handler) # type: ignore[arg-type]
116127
app.add_exception_handler(ApplicationError, application_exception_handler) # type: ignore[arg-type]
117128
app.add_exception_handler(AuthorizationError, application_exception_handler) # type: ignore[arg-type]
118129
app.add_exception_handler(

data_rentgen/server/api/v1/router/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
from data_rentgen.server.api.v1.router.location import router as location_router
99
from data_rentgen.server.api.v1.router.operation import router as operation_router
1010
from data_rentgen.server.api.v1.router.run import router as run_router
11+
from data_rentgen.server.api.v1.router.user import router as user_router
1112

1213
router = APIRouter(prefix="/v1")
1314
router.include_router(auth_router)
@@ -16,3 +17,4 @@
1617
router.include_router(location_router)
1718
router.include_router(operation_router)
1819
router.include_router(run_router)
20+
router.include_router(user_router)

data_rentgen/server/api/v1/router/auth.py

Lines changed: 28 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,21 +1,29 @@
11
# SPDX-FileCopyrightText: 2024 MTS PJSC
22
# SPDX-License-Identifier: Apache-2.0
3+
from http import HTTPStatus
34
from typing import Annotated
45

5-
from fastapi import APIRouter, Depends
6+
from fastapi import APIRouter, Depends, Request
67
from fastapi.security import OAuth2PasswordRequestForm
78

89
from data_rentgen.dependencies import Stub
910
from data_rentgen.server.errors.registration import get_error_responses
1011
from data_rentgen.server.errors.schemas.invalid_request import InvalidRequestSchema
11-
from data_rentgen.server.errors.schemas.not_authorized import NotAuthorizedSchema
12-
from data_rentgen.server.providers.auth import AuthProvider, DummyAuthProvider
12+
from data_rentgen.server.errors.schemas.not_authorized import (
13+
NotAuthorizedRedirectSchema,
14+
NotAuthorizedSchema,
15+
)
16+
from data_rentgen.server.providers.auth import (
17+
AuthProvider,
18+
DummyAuthProvider,
19+
KeycloakAuthProvider,
20+
)
1321
from data_rentgen.server.schemas.v1.auth import AuthTokenSchema
1422

1523
router = APIRouter(
1624
prefix="/auth",
1725
tags=["Auth"],
18-
responses=get_error_responses(include={NotAuthorizedSchema, InvalidRequestSchema}),
26+
responses=get_error_responses(include={NotAuthorizedSchema, InvalidRequestSchema, NotAuthorizedRedirectSchema}),
1927
)
2028

2129

@@ -28,3 +36,19 @@ async def token(
2836
login=form_data.username,
2937
)
3038
return AuthTokenSchema.model_validate(user_token)
39+
40+
41+
@router.get("/callback")
42+
async def auth_callback(
43+
request: Request,
44+
code: str,
45+
auth_provider: Annotated[KeycloakAuthProvider, Depends(Stub(AuthProvider))],
46+
):
47+
code_grant = await auth_provider.get_token_authorization_code_grant(
48+
code=code,
49+
redirect_uri=auth_provider.settings.keycloak.redirect_uri,
50+
)
51+
request.session["access_token"] = code_grant["access_token"]
52+
request.session["refresh_token"] = code_grant["refresh_token"]
53+
54+
return HTTPStatus.OK
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
# SPDX-FileCopyrightText: 2024 MTS PJSC
2+
# SPDX-License-Identifier: Apache-2.0
3+
from logging import getLogger
4+
5+
from fastapi import APIRouter, Depends
6+
7+
from data_rentgen.db.models.user import User
8+
from data_rentgen.server.errors.registration import get_error_responses
9+
from data_rentgen.server.schemas.v1.user import UserResponseV1
10+
from data_rentgen.server.services import get_user
11+
12+
logger = getLogger(__name__)
13+
14+
15+
router = APIRouter(
16+
prefix="/users",
17+
tags=["User"],
18+
responses=get_error_responses(),
19+
)
20+
21+
22+
@router.get("/me")
23+
async def check_auth(
24+
current_user: User = Depends(get_user()),
25+
) -> UserResponseV1:
26+
logger.info("User check: %s", current_user.name)
27+
return UserResponseV1.model_validate(current_user)
Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,15 @@
11
# SPDX-FileCopyrightText: 2024 MTS PJSC
22
# SPDX-License-Identifier: Apache-2.0
33
from data_rentgen.server.errors.schemas.invalid_request import InvalidRequestSchema
4-
from data_rentgen.server.errors.schemas.not_authorized import NotAuthorizedSchema
4+
from data_rentgen.server.errors.schemas.not_authorized import (
5+
NotAuthorizedRedirectSchema,
6+
NotAuthorizedSchema,
7+
)
58
from data_rentgen.server.errors.schemas.not_found import NotFoundSchema
69

710
__all__ = [
811
"InvalidRequestSchema",
912
"NotFoundSchema",
1013
"NotAuthorizedSchema",
14+
"NotAuthorizedRedirectSchema",
1115
]

data_rentgen/server/errors/schemas/not_authorized.py

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
from typing import Any, Literal
55

66
from data_rentgen.exceptions.auth import AuthorizationError
7+
from data_rentgen.exceptions.redirect import RedirectError
78
from data_rentgen.server.errors.base import BaseErrorSchema
89
from data_rentgen.server.errors.registration import register_error_response
910

@@ -15,3 +16,17 @@
1516
class NotAuthorizedSchema(BaseErrorSchema):
1617
code: Literal["unauthorized"] = "unauthorized"
1718
details: Any = None
19+
20+
21+
@register_error_response(
22+
exception=RedirectError,
23+
status=http.HTTPStatus.UNAUTHORIZED,
24+
)
25+
class NotAuthorizedRedirectSchema(BaseErrorSchema):
26+
"""
27+
The reason of using UNAUTHORIZED instead of strict redirect is:
28+
Fronted is using `fetch()` function which can't handle redirect responses
29+
https://github.com/whatwg/fetch/issues/601
30+
"""
31+
32+
code: Literal["auth_redirect"] = "auth_redirect"

data_rentgen/server/providers/auth/keycloak_provider.py

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,7 @@ async def get_token_authorization_code_grant(
6969
redirect_uri=redirect_uri,
7070
)
7171
except Exception as e:
72+
logger.error("Error when trying to get token: %s", e)
7273
raise AuthorizationError("Failed to get token") from e
7374

7475
async def get_current_user(self, access_token: str, *args, **kwargs) -> User:
@@ -82,7 +83,6 @@ async def get_current_user(self, access_token: str, *args, **kwargs) -> User:
8283
# if user is disabled or blocked in Keycloak after the token is issued, he will
8384
# remain authorized until the token expires (not more than 15 minutes in MTS SSO)
8485
token_info = self.decode_token(access_token)
85-
8686
if token_info is None and refresh_token:
8787
logger.debug("Access token invalid. Attempting to refresh.")
8888
access_token, refresh_token = self.refresh_access_token(refresh_token)
@@ -119,9 +119,12 @@ def refresh_access_token(self, refresh_token: str) -> tuple[str, str]: # type:
119119
logger.debug("Failed to refresh access token: %s", err)
120120
self.redirect_to_auth()
121121

122-
def redirect_to_auth(self) -> None:
122+
def redirect_to_auth(self):
123+
123124
auth_url = self.keycloak_openid.auth_url(
124125
redirect_uri=self.settings.keycloak.redirect_uri,
125126
scope=self.settings.keycloak.scope,
126127
)
127-
raise RedirectError(message=auth_url, details="Authorize on provided url")
128+
129+
logger.info("Raising redirect error with url: %s", auth_url)
130+
raise RedirectError(message="Please authorize using provided URL", details=auth_url)

docs/reference/server/auth/keycloak.rst

Lines changed: 44 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -22,51 +22,81 @@ Interaction schema
2222

2323
@startuml
2424
title DummyAuthProvider
25-
participant "Client"
25+
participant "Frontend"
2626
participant "Backend"
27+
participant "Keycloak"
28+
29+
== Frontend Authentication at Keycloak ==
30+
31+
Frontend -> Backend : Request endpoint with authentication (/v1/locations)
32+
33+
Backend x-[#red]> Frontend: 401 with redirect url in 'details' response field
34+
35+
Frontend -> Keycloak : Redirect user to Keycloak login page
36+
37+
alt Successful login
38+
Frontend --> Keycloak : Log in with login and password
39+
else Login failed
40+
Keycloak x-[#red]> Frontend -- : Display error (401 Unauthorized)
41+
end
42+
43+
Keycloak -> Frontend : Callback to Frontend /callback which is proxy between Keycloak and Backend
44+
45+
Frontend -> Backend : Send request to Backend '/v1/auth/callback'
46+
47+
Backend -> Keycloak : Check original 'state' and exchange code for token's
48+
Keycloak --> Backend : Return token's
49+
Backend --> Frontend : Set token's in user's browser in cookies
50+
51+
Frontend --> Backend : Request to /v1/locations with session cookies
52+
Backend -> Backend : Get user info from token and check user in internal backend database
53+
Backend -> Backend : Create user in internal backend database if not exist
54+
Backend -[#green]> Frontend -- : Return requested data
55+
2756

2857
== GET v1/datasets ==
2958

59+
3060
alt Successful case
31-
"Client" -> "Backend" ++ : access_token
61+
"Frontend" -> "Backend" ++ : access_token
3262
"Backend" --> "Backend" : Validate token
3363
"Backend" --> "Backend" : Check user in internal backend database
3464
"Backend" -> "Backend" : Get data
35-
"Backend" -[#green]> "Client" -- : Return data
65+
"Backend" -[#green]> "Frontend" -- : Return data
3666

3767
else Token is expired (Successful case)
38-
"Client" -> "Backend" ++ : access_token, refresh_token
68+
"Frontend" -> "Backend" ++ : access_token, refresh_token
3969
"Backend" --> "Backend" : Validate token
4070
"Backend" -[#yellow]> "Backend" : Token is expired
41-
"Backend" --> "Backend" : Try to refresh token
71+
"Backend" --> "Keycloak" : Try to refresh token
4272
"Backend" --> "Backend" : Validate new token
4373
"Backend" --> "Backend" : Check user in internal backend database
4474
"Backend" -> "Backend" : Get data
45-
"Backend" -[#green]> "Client" -- : Return data
75+
"Backend" -[#green]> "Frontend" -- : Return data
4676

4777
else Create new User
48-
"Client" -> "Backend" ++ : access_token
78+
"Frontend" -> "Backend" ++ : access_token
4979
"Backend" --> "Backend" : Validate token
5080
"Backend" --> "Backend" : Check user in internal backend database
5181
"Backend" --> "Backend" : Create new user
5282
"Backend" -> "Backend" : Get data
53-
"Backend" -[#green]> "Client" -- : Return data
83+
"Backend" -[#green]> "Frontend" -- : Return data
5484

5585
else Token is expired and bad refresh token
56-
"Client" -> "Backend" ++ : access_token, refresh_token
86+
"Frontend" -> "Backend" ++ : access_token, refresh_token
5787
"Backend" --> "Backend" : Validate token
5888
"Backend" -[#yellow]> "Backend" : Token is expired
59-
"Backend" --> "Backend" : Try to refresh token
60-
"Backend" x-[#red]> "Client" -- : RedirectResponse can't refresh
89+
"Backend" --> "Keycloak" : Try to refresh token
90+
"Backend" x-[#red]> "Frontend" -- : RedirectResponse can't refresh
6191

6292
else Bad Token payload
63-
"Client" -> "Backend" ++ : access_token, refresh_token
93+
"Frontend" -> "Backend" ++ : access_token, refresh_token
6494
"Backend" --> "Backend" : Validate token
65-
"Backend" x-[#red]> "Client" -- : 307 Authorization error
95+
"Backend" x-[#red]> "Frontend" -- : 307 Authorization error
6696

6797
end
6898

69-
deactivate "Client"
99+
deactivate "Frontend"
70100
@enduml
71101

72102

0 commit comments

Comments
 (0)