Skip to content

Commit 5afca4a

Browse files
committed
[DOP-21576] Update Keycloak provider for auth with Frontend
1 parent af8126f commit 5afca4a

File tree

8 files changed

+185
-30
lines changed

8 files changed

+185
-30
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/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: 14 additions & 3 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 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(

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

Lines changed: 50 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,21 +1,37 @@
11
# SPDX-FileCopyrightText: 2024 MTS PJSC
22
# SPDX-License-Identifier: Apache-2.0
3+
import base64
4+
from http import HTTPStatus
5+
from logging import getLogger
36
from typing import Annotated
47

5-
from fastapi import APIRouter, Depends
8+
from fastapi import APIRouter, Depends, HTTPException, Request
69
from fastapi.security import OAuth2PasswordRequestForm
710

11+
from data_rentgen.db.models.user import User
812
from data_rentgen.dependencies import Stub
913
from data_rentgen.server.errors.registration import get_error_responses
1014
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
15+
from data_rentgen.server.errors.schemas.not_authorized import (
16+
NotAuthorizedRedirectSchema,
17+
NotAuthorizedSchema,
18+
)
19+
from data_rentgen.server.providers.auth import (
20+
AuthProvider,
21+
DummyAuthProvider,
22+
KeycloakAuthProvider,
23+
)
1324
from data_rentgen.server.schemas.v1.auth import AuthTokenSchema
25+
from data_rentgen.server.schemas.v1.user import UserResponseV1
26+
from data_rentgen.server.services import get_user
27+
28+
logger = getLogger(__name__)
29+
1430

1531
router = APIRouter(
1632
prefix="/auth",
1733
tags=["Auth"],
18-
responses=get_error_responses(include={NotAuthorizedSchema, InvalidRequestSchema}),
34+
responses=get_error_responses(include={NotAuthorizedSchema, InvalidRequestSchema, NotAuthorizedRedirectSchema}),
1935
)
2036

2137

@@ -28,3 +44,33 @@ async def token(
2844
login=form_data.username,
2945
)
3046
return AuthTokenSchema.model_validate(user_token)
47+
48+
49+
@router.get("/callback")
50+
async def auth_callback(
51+
request: Request,
52+
state: str,
53+
code: str,
54+
auth_provider: Annotated[KeycloakAuthProvider, Depends(Stub(AuthProvider))],
55+
):
56+
state = base64.b64decode(state.encode("utf-8")) # type: ignore[assignment]
57+
original_url = state.decode("utf-8") # type: ignore[attr-defined]
58+
59+
if not original_url:
60+
raise HTTPException(status_code=HTTPStatus.BAD_REQUEST, detail="Invalid original_url")
61+
code_grant = await auth_provider.get_token_authorization_code_grant(
62+
code=code,
63+
redirect_uri=auth_provider.settings.keycloak.redirect_uri,
64+
)
65+
request.session["access_token"] = code_grant["access_token"]
66+
request.session["refresh_token"] = code_grant["refresh_token"]
67+
68+
return HTTPStatus.OK
69+
70+
71+
@router.get("/me")
72+
async def check_auth(
73+
current_user: User = Depends(get_user()),
74+
):
75+
logger.info("User check: %s", current_user.name)
76+
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: 33 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,13 @@
11
# SPDX-FileCopyrightText: 2024 MTS PJSC
22
# SPDX-License-Identifier: Apache-2.0
3+
import base64
34
import logging
5+
from http import HTTPStatus
46
from typing import Annotated, Any
57

6-
from fastapi import Depends, FastAPI, Request
8+
from fastapi import Depends, FastAPI, HTTPException, Request
79
from keycloak import KeycloakOpenID
10+
from keycloak.exceptions import KeycloakConnectionError
811

912
from data_rentgen.db.models import User
1013
from data_rentgen.dependencies import Stub
@@ -69,6 +72,7 @@ async def get_token_authorization_code_grant(
6972
redirect_uri=redirect_uri,
7073
)
7174
except Exception as e:
75+
logger.error("Error when trying to get token: %s", e)
7276
raise AuthorizationError("Failed to get token") from e
7377

7478
async def get_current_user(self, access_token: str, *args, **kwargs) -> User:
@@ -77,23 +81,22 @@ async def get_current_user(self, access_token: str, *args, **kwargs) -> User:
7781

7882
if not access_token:
7983
logger.debug("No access token found in session.")
80-
self.redirect_to_auth()
84+
self.redirect_to_auth(str(request.url))
8185

8286
# if user is disabled or blocked in Keycloak after the token is issued, he will
8387
# remain authorized until the token expires (not more than 15 minutes in MTS SSO)
8488
token_info = self.decode_token(access_token)
85-
8689
if token_info is None and refresh_token:
8790
logger.debug("Access token invalid. Attempting to refresh.")
88-
access_token, refresh_token = self.refresh_access_token(refresh_token)
91+
access_token, refresh_token = self.refresh_access_token(refresh_token, str(request.url))
8992
request.session["access_token"] = access_token
9093
request.session["refresh_token"] = refresh_token
9194

9295
token_info = self.decode_token(access_token)
9396

9497
if token_info is None:
9598
# If there is no token_info after refresh user get redirect
96-
self.redirect_to_auth()
99+
self.redirect_to_auth(str(request.url))
97100

98101
# these names are hardcoded in keycloak:
99102
# https://github.com/keycloak/keycloak/blob/3ca3a4ad349b4d457f6829eaf2ae05f1e01408be/core/src/main/java/org/keycloak/representations/IDToken.java
@@ -103,25 +106,42 @@ async def get_current_user(self, access_token: str, *args, **kwargs) -> User:
103106
raise AuthorizationError("Invalid token payload")
104107
return await self._uow.user.get_or_create(UserDTO(name=login)) # type: ignore[arg-type]
105108

109+
async def logout(self, refresh_token: str):
110+
try:
111+
return self.keycloak_openid.logout(refresh_token)
112+
except KeycloakConnectionError as err:
113+
logger.error("Error when trying to get token: %s", err)
114+
return None
115+
106116
def decode_token(self, access_token: str) -> dict[str, Any] | None:
107117
try:
108118
return self.keycloak_openid.decode_token(token=access_token)
109119
except Exception as err:
110120
logger.info("Access token is invalid or expired: %s", err)
111121
return None
112122

113-
def refresh_access_token(self, refresh_token: str) -> tuple[str, str]: # type: ignore[return]
123+
def refresh_access_token(self, refresh_token: str, origin_url: str) -> tuple[str, str]: # type: ignore[return]
114124
try:
115125
new_tokens = self.keycloak_openid.refresh_token(refresh_token)
116126
logger.debug("Access token refreshed")
117127
return new_tokens.get("access_token"), new_tokens.get("refresh_token")
118128
except Exception as err:
119129
logger.debug("Failed to refresh access token: %s", err)
120-
self.redirect_to_auth()
130+
self.redirect_to_auth(origin_url)
121131

122-
def redirect_to_auth(self) -> None:
123-
auth_url = self.keycloak_openid.auth_url(
124-
redirect_uri=self.settings.keycloak.redirect_uri,
125-
scope=self.settings.keycloak.scope,
126-
)
127-
raise RedirectError(message=auth_url, details="Authorize on provided url")
132+
def redirect_to_auth(self, state: str = ""):
133+
try:
134+
state = base64.b64encode(state.encode("utf-8")) # type: ignore[assignment]
135+
136+
auth_url = self.keycloak_openid.auth_url(
137+
redirect_uri=self.settings.keycloak.redirect_uri,
138+
scope=self.settings.keycloak.scope,
139+
state=state.decode("utf-8"), # type: ignore[attr-defined]
140+
)
141+
142+
except KeycloakConnectionError as err:
143+
logger.error("Failed connect to Keycloak: %s", err)
144+
# TODO: What exception should we raise here?
145+
raise HTTPException(status_code=HTTPStatus.BAD_REQUEST, detail=err)
146+
logger.info("Raising redirect error with url: %s", auth_url)
147+
raise RedirectError(message="Authorize on provided url", details=auth_url)

docs/reference/server/auth/keycloak.rst

Lines changed: 32 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -24,9 +24,39 @@ Interaction schema
2424
title DummyAuthProvider
2525
participant "Client"
2626
participant "Backend"
27+
participant "Keycloak"
28+
29+
== Client Authentication at Keycloak ==
30+
31+
Client -> Backend : Request endpoint with authentication (/locations)
32+
33+
Backend x-[#red]> Client: 401 with redirect url in 'details' response field
34+
35+
Client -> Keycloak : Redirect user to Keycloak login page
36+
37+
alt Successful login
38+
Client --> Keycloak : Log in with login and password
39+
else Login failed
40+
Keycloak x-[#red]> Client -- : Display error (401 Unauthorized)
41+
end
42+
43+
Keycloak -> Client : Callback to Client /callback which is proxy between Keycloak and Backend
44+
45+
Client -> 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 --> Client : Set token's in user's browser in cookies and redirect /locations
50+
51+
Client --> Backend : Redirect to /locations
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]> Client -- : Return requested data
55+
2756

2857
== GET v1/datasets ==
2958

59+
3060
alt Successful case
3161
"Client" -> "Backend" ++ : access_token
3262
"Backend" --> "Backend" : Validate token
@@ -38,7 +68,7 @@ Interaction schema
3868
"Client" -> "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
@@ -56,7 +86,7 @@ Interaction schema
5686
"Client" -> "Backend" ++ : access_token, refresh_token
5787
"Backend" --> "Backend" : Validate token
5888
"Backend" -[#yellow]> "Backend" : Token is expired
59-
"Backend" --> "Backend" : Try to refresh token
89+
"Backend" --> "Keycloak" : Try to refresh token
6090
"Backend" x-[#red]> "Client" -- : RedirectResponse can't refresh
6191

6292
else Bad Token payload

tests/test_server/test_auth/test_keycloak_auth.py

Lines changed: 24 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import base64
12
import logging
23

34
import pytest
@@ -7,6 +8,7 @@
78

89
from data_rentgen.db.models import Dataset, User
910
from data_rentgen.server.settings import ServerApplicationSettings as Settings
11+
from data_rentgen.server.settings.auth.keycloak import KeycloakSettings
1012
from tests.test_server.utils.enrich import enrich_datasets
1113

1214
KEYCLOAK_PROVIDER = "data_rentgen.server.providers.auth.keycloak_provider.KeycloakAuthProvider"
@@ -25,14 +27,32 @@
2527
],
2628
indirect=True,
2729
)
28-
async def test_get_keycloak_user_unauthorized(test_client: AsyncClient, mock_keycloak_well_known, caplog):
30+
async def test_get_keycloak_user_unauthorized(
31+
test_client: AsyncClient,
32+
mock_keycloak_well_known,
33+
caplog,
34+
server_app_settings: Settings,
35+
):
36+
k_settings = KeycloakSettings.model_validate(server_app_settings.auth.keycloak)
37+
2938
response = await test_client.get("/v1/datasets")
3039

3140
# redirect unauthorized user to Keycloak
32-
assert response.status_code == 307
33-
assert "protocol/openid-connect/auth?" in str(
34-
response.next_request.url,
41+
state = str(response.url).encode("utf-8")
42+
state = base64.b64encode(state)
43+
redirect_url = (
44+
f"{k_settings.server_url}/realms/{k_settings.realm_name}/protocol/openid-connect/auth?client_id="
45+
f"{k_settings.client_id}&response_type=code&redirect_uri={k_settings.redirect_uri}"
46+
f"&scope={k_settings.scope}&state={state.decode('utf-8')}&nonce="
3547
)
48+
assert response.status_code == 401
49+
assert response.json() == {
50+
"error": {
51+
"code": "auth_redirect",
52+
"message": "Authorize on provided url",
53+
"details": redirect_url,
54+
},
55+
}
3656

3757

3858
@responses.activate

0 commit comments

Comments
 (0)