Skip to content

Commit b42eb24

Browse files
author
Andrej Simurka
committed
Updated exception handling in internal logic
1 parent f658cd4 commit b42eb24

File tree

12 files changed

+238
-191
lines changed

12 files changed

+238
-191
lines changed

src/authentication/jwk_token.py

Lines changed: 52 additions & 51 deletions
Original file line numberDiff line numberDiff line change
@@ -1,26 +1,28 @@
11
"""Manage authentication flow for FastAPI endpoints with JWK based JWT auth."""
22

3+
import json
34
import logging
45
from asyncio import Lock
56
from typing import Any, Callable
67

7-
from fastapi import Request, HTTPException, status
8-
from authlib.jose import JsonWebKey, KeySet, jwt, Key
8+
import aiohttp
9+
from authlib.jose import JsonWebKey, Key, KeySet, jwt
910
from authlib.jose.errors import (
1011
BadSignatureError,
1112
DecodeError,
1213
ExpiredTokenError,
1314
JoseError,
1415
)
1516
from cachetools import TTLCache
16-
import aiohttp
17+
from fastapi import HTTPException, Request
1718

19+
from authentication.interface import NO_AUTH_TUPLE, AuthInterface, AuthTuple
20+
from authentication.utils import extract_user_token
1821
from constants import (
1922
DEFAULT_VIRTUAL_PATH,
2023
)
21-
from authentication.interface import NO_AUTH_TUPLE, AuthInterface, AuthTuple
22-
from authentication.utils import extract_user_token
2324
from models.config import JwkConfiguration
25+
from models.responses import UnauthorizedResponse
2426

2527
logger = logging.getLogger(__name__)
2628

@@ -126,68 +128,67 @@ async def __call__(self, request: Request) -> AuthTuple:
126128
return NO_AUTH_TUPLE
127129

128130
user_token = extract_user_token(request.headers)
129-
jwk_set = await get_jwk_set(str(self.config.url))
130131

131132
try:
132-
claims = jwt.decode(user_token, key=key_resolver_func(jwk_set))
133-
except KeyNotFoundError as exc:
134-
raise HTTPException(
135-
status_code=status.HTTP_401_UNAUTHORIZED,
136-
detail="Invalid token: signed by unknown key or algorithm mismatch",
137-
) from exc
138-
except BadSignatureError as exc:
139-
raise HTTPException(
140-
status_code=status.HTTP_401_UNAUTHORIZED,
141-
detail="Invalid token: bad signature",
142-
) from exc
143-
except DecodeError as exc:
144-
raise HTTPException(
145-
status_code=status.HTTP_400_BAD_REQUEST,
146-
detail="Invalid token: decode error",
147-
) from exc
133+
jwk_set = await get_jwk_set(str(self.config.url))
134+
except aiohttp.ClientError as exc:
135+
logger.error("Failed to fetch JWK set: %s", exc)
136+
response = UnauthorizedResponse(
137+
cause="Unable to reach authentication key server"
138+
)
139+
raise HTTPException(**response.model_dump()) from exc
140+
except json.JSONDecodeError as exc:
141+
logger.error("Invalid JSON in JWK set response: %s", exc)
142+
response = UnauthorizedResponse(
143+
cause="Authentication key server returned invalid data"
144+
)
145+
raise HTTPException(**response.model_dump()) from exc
148146
except JoseError as exc:
149-
raise HTTPException(
150-
status_code=status.HTTP_400_BAD_REQUEST,
151-
detail="Invalid token: unknown error",
152-
) from exc
153-
except Exception as exc:
154-
raise HTTPException(
155-
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
156-
detail="Internal server error",
157-
) from exc
147+
logger.error("Invalid JWK set format: %s", exc)
148+
response = UnauthorizedResponse(cause="Authentication keys are malformed")
149+
raise HTTPException(**response.model_dump()) from exc
150+
151+
try:
152+
claims = jwt.decode(user_token, key=key_resolver_func(jwk_set))
153+
except (KeyNotFoundError, BadSignatureError, DecodeError, JoseError) as exc:
154+
logger.warning("Token decode error: %s", exc)
155+
cause_map = {
156+
KeyNotFoundError: "Token signed by unknown key",
157+
BadSignatureError: "Invalid token signature",
158+
DecodeError: "Token could not be decoded",
159+
JoseError: "Token format error",
160+
}
161+
response = UnauthorizedResponse(
162+
cause=cause_map.get(type(exc), "Unknown token error")
163+
)
164+
raise HTTPException(**response.model_dump()) from exc
158165

159166
try:
160167
claims.validate()
161168
except ExpiredTokenError as exc:
162-
raise HTTPException(
163-
status_code=status.HTTP_401_UNAUTHORIZED, detail="Token has expired"
164-
) from exc
169+
response = UnauthorizedResponse(cause="Token has expired")
170+
raise HTTPException(**response.model_dump()) from exc
165171
except JoseError as exc:
166-
raise HTTPException(
167-
status_code=status.HTTP_401_UNAUTHORIZED,
168-
detail="Error validating token",
169-
) from exc
170-
except Exception as exc:
171-
raise HTTPException(
172-
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
173-
detail="Internal server error during token validation",
174-
) from exc
172+
response = UnauthorizedResponse(cause="Token validation failed")
173+
raise HTTPException(**response.model_dump()) from exc
175174

176175
try:
177176
user_id: str = claims[self.config.jwt_configuration.user_id_claim]
178177
except KeyError as exc:
179-
raise HTTPException(
180-
status_code=status.HTTP_401_UNAUTHORIZED,
181-
detail=f"Token missing claim: {self.config.jwt_configuration.user_id_claim}",
182-
) from exc
178+
missing_claim = self.config.jwt_configuration.user_id_claim
179+
response = UnauthorizedResponse(
180+
cause=f"Token missing claim: {missing_claim}"
181+
)
182+
raise HTTPException(**response.model_dump()) from exc
183183

184184
try:
185185
username: str = claims[self.config.jwt_configuration.username_claim]
186186
except KeyError as exc:
187-
raise HTTPException(
188-
status_code=status.HTTP_401_UNAUTHORIZED,
189-
detail=f"Token missing claim: {self.config.jwt_configuration.username_claim}",
190-
) from exc
187+
missing_claim = self.config.jwt_configuration.username_claim
188+
response = UnauthorizedResponse(
189+
cause=f"Token missing claim: {missing_claim}"
190+
)
191+
raise HTTPException(**response.model_dump()) from exc
191192

192193
logger.info("Successfully authenticated user %s (ID: %s)", username, user_id)
193194

src/authentication/k8s.py

Lines changed: 60 additions & 59 deletions
Original file line numberDiff line numberDiff line change
@@ -4,15 +4,22 @@
44
import os
55
from pathlib import Path
66
from typing import Optional, Self
7-
from fastapi import Request, HTTPException
87

98
import kubernetes.client
9+
from fastapi import HTTPException, Request
1010
from kubernetes.client.rest import ApiException
1111
from kubernetes.config import ConfigException
1212

13-
from configuration import configuration
1413
from authentication.interface import AuthInterface
14+
from authentication.utils import extract_user_token
15+
from configuration import configuration
1516
from constants import DEFAULT_VIRTUAL_PATH
17+
from models.responses import (
18+
ForbiddenResponse,
19+
InternalServerErrorResponse,
20+
ServiceUnavailableResponse,
21+
UnauthorizedResponse,
22+
)
1623

1724
logger = logging.getLogger(__name__)
1825

@@ -172,8 +179,20 @@ def get_user_info(token: str) -> Optional[kubernetes.client.V1TokenReview]:
172179
173180
Returns:
174181
The user information if the token is valid, None otherwise.
182+
183+
Raises:
184+
HTTPException: If unable to connect to Kubernetes API or unexpected error occurs.
175185
"""
176-
auth_api = K8sClientSingleton.get_authn_api()
186+
try:
187+
auth_api = K8sClientSingleton.get_authn_api()
188+
except Exception as e:
189+
logger.error("Failed to get Kubernetes authentication API: %s", e)
190+
response = ServiceUnavailableResponse(
191+
backend_name="Kubernetes API",
192+
cause="Unable to initialize Kubernetes client",
193+
)
194+
raise HTTPException(**response.model_dump()) from e
195+
177196
token_review = kubernetes.client.V1TokenReview(
178197
spec=kubernetes.client.V1TokenReviewSpec(token=token)
179198
)
@@ -182,31 +201,9 @@ def get_user_info(token: str) -> Optional[kubernetes.client.V1TokenReview]:
182201
if response.status.authenticated:
183202
return response.status
184203
return None
185-
except ApiException as e:
204+
except Exception as e: # pylint: disable=broad-exception-caught
186205
logger.error("API exception during TokenReview: %s", e)
187206
return None
188-
except Exception as e:
189-
logger.error("Unexpected error during TokenReview - Unauthorized: %s", e)
190-
raise HTTPException(
191-
status_code=500,
192-
detail={"response": "Forbidden: Unable to Review Token", "cause": str(e)},
193-
) from e
194-
195-
196-
def _extract_bearer_token(header: str) -> str:
197-
"""Extract the bearer token from an HTTP authorization header.
198-
199-
Args:
200-
header: The authorization header containing the token.
201-
202-
Returns:
203-
The extracted token if present, else an empty string.
204-
"""
205-
try:
206-
scheme, token = header.split(" ", 1)
207-
return token if scheme.lower() == "bearer" else ""
208-
except ValueError:
209-
return ""
210207

211208

212209
class K8SAuthDependency(AuthInterface): # pylint: disable=too-few-public-methods
@@ -239,47 +236,51 @@ async def __call__(self, request: Request) -> tuple[str, str, bool, str]:
239236
user_id check should never be skipped with K8s authentication
240237
If user_id check should be skipped - always return False for k8s
241238
User's token
242-
"""
243-
authorization_header = request.headers.get("Authorization")
244-
if not authorization_header:
245-
raise HTTPException(
246-
status_code=401, detail="Unauthorized: No auth header found"
247-
)
248-
249-
token = _extract_bearer_token(authorization_header)
250-
if not token:
251-
raise HTTPException(
252-
status_code=401,
253-
detail="Unauthorized: Bearer token not found or invalid",
254-
)
255239
240+
Raises:
241+
HTTPException: If authentication or authorization fails.
242+
"""
243+
token = extract_user_token(request.headers)
256244
user_info = get_user_info(token)
245+
257246
if user_info is None:
258-
raise HTTPException(
259-
status_code=403, detail="Forbidden: Invalid or expired token"
260-
)
247+
response = UnauthorizedResponse(cause="Invalid or expired Kubernetes token")
248+
raise HTTPException(**response.model_dump())
249+
261250
if user_info.user.username == "kube:admin":
262-
user_info.user.uid = K8sClientSingleton.get_cluster_id()
263-
authorization_api = K8sClientSingleton.get_authz_api()
264-
265-
sar = kubernetes.client.V1SubjectAccessReview(
266-
spec=kubernetes.client.V1SubjectAccessReviewSpec(
267-
user=user_info.user.username,
268-
groups=user_info.user.groups,
269-
non_resource_attributes=kubernetes.client.V1NonResourceAttributes(
270-
path=self.virtual_path, verb="get"
271-
),
272-
)
273-
)
251+
try:
252+
user_info.user.uid = K8sClientSingleton.get_cluster_id()
253+
except ClusterIDUnavailableError as e:
254+
logger.error("Failed to get cluster ID: %s", e)
255+
response = InternalServerErrorResponse(
256+
response="Internal server error",
257+
cause="Unable to retrieve cluster ID",
258+
)
259+
raise HTTPException(**response.model_dump()) from e
260+
274261
try:
262+
authorization_api = K8sClientSingleton.get_authz_api()
263+
sar = kubernetes.client.V1SubjectAccessReview(
264+
spec=kubernetes.client.V1SubjectAccessReviewSpec(
265+
user=user_info.user.username,
266+
groups=user_info.user.groups,
267+
non_resource_attributes=kubernetes.client.V1NonResourceAttributes(
268+
path=self.virtual_path, verb="get"
269+
),
270+
)
271+
)
275272
response = authorization_api.create_subject_access_review(sar)
273+
276274
if not response.status.allowed:
277-
raise HTTPException(
278-
status_code=403, detail="Forbidden: User does not have access"
279-
)
280-
except ApiException as e:
275+
response = ForbiddenResponse.endpoint(user_id=user_info.user.uid)
276+
raise HTTPException(**response.model_dump())
277+
except Exception as e:
281278
logger.error("API exception during SubjectAccessReview: %s", e)
282-
raise HTTPException(status_code=403, detail="Internal server error") from e
279+
response = ServiceUnavailableResponse(
280+
backend_name="Kubernetes API",
281+
cause="Unable to perform authorization check",
282+
)
283+
raise HTTPException(**response.model_dump()) from e
283284

284285
return (
285286
user_info.user.uid,

src/authentication/utils.py

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -2,25 +2,25 @@
22

33
from fastapi import HTTPException
44
from starlette.datastructures import Headers
5+
from models.responses import UnauthorizedResponse
56

67

78
def extract_user_token(headers: Headers) -> str:
89
"""Extract the bearer token from an HTTP authorization header.
910
1011
Args:
1112
header: The authorization header containing the token.
12-
1313
Returns:
1414
The extracted token if present, else an empty string.
1515
"""
1616
authorization_header = headers.get("Authorization")
1717
if not authorization_header:
18-
raise HTTPException(status_code=400, detail="No Authorization header found")
18+
response = UnauthorizedResponse(cause="No Authorization header found")
19+
raise HTTPException(**response.model_dump())
1920

2021
scheme_and_token = authorization_header.strip().split()
2122
if len(scheme_and_token) != 2 or scheme_and_token[0].lower() != "bearer":
22-
raise HTTPException(
23-
status_code=400, detail="No token found in Authorization header"
24-
)
23+
response = UnauthorizedResponse(cause="No token found in Authorization header")
24+
raise HTTPException(**response.model_dump())
2525

2626
return scheme_and_token[1]

0 commit comments

Comments
 (0)