Skip to content

Commit 7360d6c

Browse files
committed
feat: add keycloak
1 parent d68a1f9 commit 7360d6c

File tree

12 files changed

+564
-48
lines changed

12 files changed

+564
-48
lines changed

.gitignore

+1-1
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
# Mac OSX hidden files
22
.DS_Store
3-
3+
venv
44
# Byte-compiled / optimized / DLL files
55
*.py[cod]
66
__pycache__/
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
from keycloak import KeycloakOpenID
2+
from keycloak.exceptions import KeycloakConnectionError
3+
4+
5+
class KeycloakClient:
6+
def __init__(self, server_url, client_id, realm_name, client_secret_key, redirect_uri):
7+
self.client_id = client_id
8+
self.client_secret_key = client_secret_key
9+
self.realm_name = realm_name
10+
self.redirect_uri = redirect_uri
11+
self.server_url = server_url
12+
self._client_instance: KeycloakOpenID | None = None
13+
14+
def get_client(self) -> KeycloakOpenID:
15+
if self._client_instance is None:
16+
try:
17+
self._client_instance = KeycloakOpenID(
18+
server_url=self.server_url,
19+
client_id=self.client_id,
20+
realm_name=self.realm_name,
21+
client_secret_key=self.client_secret_key,
22+
)
23+
except KeycloakConnectionError as error:
24+
raise KeycloakConnectionError(f"Failed to connect to Keycloak: {error}")
25+
except Exception as error:
26+
raise Exception(f"An error occurred while creating Keycloak client: {error}")
27+
return self._client_instance
28+
29+
def decode_token(self, access_token: str) -> dict:
30+
"""Decode an access token.
31+
Args:
32+
access_token: An jwt access token.
33+
"""
34+
client: KeycloakOpenID = self.get_client()
35+
return client.decode_token(token=access_token)

genotype_api/config.py

+15-3
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@
22

33
from pydantic_settings import BaseSettings
44

5+
from genotype_api.clients.authentication.keycloak_client import KeycloakClient
6+
57
GENOTYPE_PACKAGE = Path(__file__).parent
68
PACKAGE_ROOT: Path = GENOTYPE_PACKAGE.parent
79
ENV_FILE: Path = PACKAGE_ROOT / ".env"
@@ -25,9 +27,11 @@ class Config:
2527
class SecuritySettings(BaseSettings):
2628
"""Settings for serving the genotype-api app"""
2729

28-
client_id: str = ""
29-
algorithm: str = ""
30-
jwks_uri: str = "https://www.googleapis.com/oauth2/v3/certs"
30+
keycloak_client_id: str = "client_id"
31+
keycloak_client_secret: str = "client_secret"
32+
keycloak_server_url: str = "server_url"
33+
keycloak_realm_name: str = "realm_name"
34+
keycloak_redirect_uri: str = "redirect_uri"
3135
api_root_path: str = "/"
3236

3337
class Config:
@@ -37,3 +41,11 @@ class Config:
3741
security_settings = SecuritySettings()
3842

3943
settings = DBSettings()
44+
45+
keycloak_client = KeycloakClient(
46+
server_url=security_settings.keycloak_server_url,
47+
client_id=security_settings.keycloak_client_id,
48+
client_secret_key=security_settings.keycloak_client_secret,
49+
realm_name=security_settings.keycloak_realm_name,
50+
redirect_uri=security_settings.keycloak_redirect_uri,
51+
)

genotype_api/exceptions.py

+8
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,10 @@ class UserNotFoundError(Exception):
2929
pass
3030

3131

32+
class UserRoleError(Exception):
33+
pass
34+
35+
3236
class UserArchiveError(Exception):
3337
pass
3438

@@ -55,3 +59,7 @@ class SNPExistsError(Exception):
5559

5660
class PlateExistsError(Exception):
5761
pass
62+
63+
64+
class AuthenticationError(Exception):
65+
pass

genotype_api/models.py

+26
Original file line numberDiff line numberDiff line change
@@ -52,3 +52,29 @@ class MatchCounts(BaseModel):
5252
class MatchResult(BaseModel):
5353
sample_id: str
5454
match_results: MatchCounts | None = None
55+
56+
57+
class RealmAccess(BaseModel):
58+
roles: list[str]
59+
60+
61+
class DecodingResponse(BaseModel):
62+
exp: int
63+
iat: int
64+
auth_time: int
65+
jti: str
66+
iss: str
67+
sub: str
68+
typ: str
69+
azp: str
70+
sid: str
71+
acr: str
72+
allowed_origins: list[str] | None = None
73+
realm_access: RealmAccess
74+
scope: str
75+
email_verified: bool
76+
name: str
77+
preferred_username: str
78+
given_name: str
79+
family_name: str
80+
email: str

genotype_api/security.py

+13-22
Original file line numberDiff line numberDiff line change
@@ -1,35 +1,22 @@
1-
import requests
21
from fastapi import Depends, HTTPException, Security
32
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
43
from jose import jwt
54
from starlette import status
65
from starlette.requests import Request
76

8-
from genotype_api.config import security_settings
7+
from genotype_api.config import security_settings, keycloak_client
98
from genotype_api.database.models import User
109
from genotype_api.database.store import Store, get_store
1110
from genotype_api.dto.user import CurrentUser
1211

13-
14-
def decode_id_token(token: str):
15-
try:
16-
payload = jwt.decode(
17-
token,
18-
key=requests.get(security_settings.jwks_uri).json(),
19-
algorithms=[security_settings.algorithm],
20-
audience=security_settings.client_id,
21-
options={
22-
"verify_at_hash": False,
23-
},
24-
)
25-
return payload
26-
except jwt.JWTError:
27-
return None
12+
from genotype_api.exceptions import AuthenticationError
13+
from genotype_api.services.authentication.service import AuthenticationService
2814

2915

3016
class JWTBearer(HTTPBearer):
31-
def __init__(self, auto_error: bool = True):
17+
def __init__(self, auth_service: AuthenticationService, auto_error: bool = True):
3218
super(JWTBearer, self).__init__(auto_error=auto_error)
19+
self.auth_service = auth_service
3320

3421
async def __call__(self, request: Request):
3522
credentials: HTTPAuthorizationCredentials = await super(JWTBearer, self).__call__(request)
@@ -48,19 +35,23 @@ async def __call__(self, request: Request):
4835

4936
def verify_jwt(self, jwtoken: str) -> dict | None:
5037
try:
51-
payload = decode_id_token(jwtoken)
38+
payload: dict = self.auth_service.verify_token(jwtoken).model_dump()
5239
if payload and "email" in payload:
5340
return {"email": payload["email"]}
5441
else:
5542
return None
56-
except jwt.JWTError:
43+
except AuthenticationError as error:
5744
raise HTTPException(
5845
status_code=status.HTTP_403_FORBIDDEN,
59-
detail="Invalid token or expired token.",
46+
detail=f"{error}",
6047
)
6148

6249

63-
jwt_scheme = JWTBearer()
50+
auth_service = AuthenticationService(
51+
redirect_uri=security_settings.keycloak_redirect_uri,
52+
keycloak_client=keycloak_client,
53+
)
54+
jwt_scheme = JWTBearer(auth_service=auth_service)
6455

6556

6657
async def get_active_user(
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
from genotype_api.clients.authentication.keycloak_client import KeycloakClient
2+
from genotype_api.exceptions import AuthenticationError, UserRoleError
3+
from genotype_api.models import DecodingResponse
4+
5+
6+
class AuthenticationService:
7+
"""Authentication service to verify tokens against Keycloak and return user information."""
8+
9+
def __init__(
10+
self,
11+
redirect_uri: str,
12+
keycloak_client: KeycloakClient,
13+
):
14+
"""Initialize the AuthenticationService
15+
16+
Args:
17+
redirect_uri: Redirect uri for keycloak
18+
keycloak_client: KeycloakOpenID client.
19+
"""
20+
self.redirect_uri = redirect_uri
21+
self.client = keycloak_client
22+
23+
def verify_token(self, jwt_token: str) -> DecodingResponse:
24+
"""Verify the token and user role.
25+
Args:
26+
token (str): The token to verify.
27+
28+
Returns:
29+
Decoded token payload
30+
31+
Raises:
32+
AuthenticationError: if an error occured the authentication
33+
"""
34+
try:
35+
decoded_token = DecodingResponse(**self.client.decode_token(jwt_token))
36+
self.check_role(decoded_token.realm_access.roles)
37+
return decoded_token
38+
except Exception as error:
39+
raise AuthenticationError(f"An error occured during authorisation: {error}")
40+
41+
@staticmethod
42+
def check_role(roles: list[str]) -> None:
43+
"""Check the user roles.
44+
Currently set to a single permissable role, expand if needed.
45+
Args:
46+
roles (list[str]): The user roles received from the RealmAccess.
47+
Raises:
48+
UserRoleError: if required role not present
49+
"""
50+
if not "cg-employee" in roles:
51+
raise UserRoleError("The user does not have the required role to access this service.")

0 commit comments

Comments
 (0)