Skip to content

Commit d7c88b5

Browse files
committed
feat: add auth monitoring metrics
Signed-off-by: Major Hayden <major@redhat.com>
1 parent 5f20928 commit d7c88b5

14 files changed

Lines changed: 855 additions & 142 deletions

File tree

src/authentication/api_key_token.py

Lines changed: 20 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,12 +7,14 @@
77
"""
88

99
import secrets
10+
import time
1011

1112
from fastapi import HTTPException, Request, status
1213

1314
from authentication.interface import AuthInterface
14-
from authentication.utils import extract_user_token
15+
from authentication.utils import extract_user_token, record_auth_metrics
1516
from constants import (
17+
AUTH_MOD_APIKEY_TOKEN,
1618
DEFAULT_USER_NAME,
1719
DEFAULT_USER_UID,
1820
DEFAULT_VIRTUAL_PATH,
@@ -59,16 +61,32 @@ async def __call__(self, request: Request) -> tuple[str, str, bool, str]:
5961
HTTPException: If the bearer token is missing or
6062
doesn't match the configured API key (HTTP 401).
6163
"""
64+
start_time = time.monotonic()
65+
6266
# try to extract user token from request
63-
user_token = extract_user_token(request.headers)
67+
try:
68+
user_token = extract_user_token(request.headers)
69+
except HTTPException as exc:
70+
# Distinguish missing header from malformed token
71+
reason = "missing_token"
72+
if isinstance(
73+
exc.detail, dict
74+
) and "No Authorization header" in exc.detail.get("cause", ""):
75+
reason = "missing_header"
76+
record_auth_metrics(AUTH_MOD_APIKEY_TOKEN, "failure", reason, start_time)
77+
raise
6478

6579
# API Key validation. Use secrets.compare_digest for constant-time comparison
6680
if not secrets.compare_digest(
6781
user_token, self.config.api_key.get_secret_value()
6882
):
83+
record_auth_metrics(
84+
AUTH_MOD_APIKEY_TOKEN, "failure", "invalid_key", start_time
85+
)
6986
raise HTTPException(
7087
status_code=status.HTTP_401_UNAUTHORIZED,
7188
detail="Invalid API Key",
7289
)
7390

91+
record_auth_metrics(AUTH_MOD_APIKEY_TOKEN, "success", "valid_key", start_time)
7492
return DEFAULT_USER_UID, DEFAULT_USER_NAME, self.skip_userid_check, user_token

src/authentication/jwk_token.py

Lines changed: 117 additions & 61 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
"""Manage authentication flow for FastAPI endpoints with JWK based JWT auth."""
22

33
import json
4+
import time
45
from asyncio import Lock
56
from collections.abc import Callable
67
from typing import Any
@@ -17,12 +18,13 @@
1718
from fastapi import HTTPException, Request
1819

1920
from authentication.interface import AuthInterface, AuthTuple
20-
from authentication.utils import extract_user_token
21+
from authentication.utils import extract_user_token, record_auth_metrics
2122
from constants import (
23+
AUTH_MOD_JWK_TOKEN,
2224
DEFAULT_VIRTUAL_PATH,
2325
)
2426
from log import get_logger
25-
from models.api.responses import UnauthorizedResponse
27+
from models.api.responses import ServiceUnavailableResponse, UnauthorizedResponse
2628
from models.config import JwkConfiguration
2729

2830
logger = get_logger(__name__)
@@ -139,6 +141,93 @@ def _internal(header: dict[str, Any], _payload: dict[str, Any]) -> Key:
139141
return _internal
140142

141143

144+
async def _get_jwk_set_for_auth(config: JwkConfiguration, start_time: float) -> KeySet:
145+
"""Load the configured JWK set and record bounded auth failures."""
146+
try:
147+
return await get_jwk_set(str(config.url))
148+
except aiohttp.ClientError as exc:
149+
logger.error("Failed to fetch JWK set: %s", exc)
150+
record_auth_metrics(
151+
AUTH_MOD_JWK_TOKEN, "failure", "jwk_fetch_error", start_time
152+
)
153+
response = ServiceUnavailableResponse(
154+
backend_name="JWK key server",
155+
cause="Unable to reach authentication key server",
156+
)
157+
raise HTTPException(**response.model_dump()) from exc
158+
except json.JSONDecodeError as exc:
159+
logger.error("Invalid JSON in JWK set response: %s", exc)
160+
record_auth_metrics(AUTH_MOD_JWK_TOKEN, "failure", "invalid_json", start_time)
161+
response = ServiceUnavailableResponse(
162+
backend_name="JWK key server",
163+
cause="Authentication key server returned invalid data",
164+
)
165+
raise HTTPException(**response.model_dump()) from exc
166+
except JoseError as exc:
167+
logger.error("Invalid JWK set format: %s", exc)
168+
record_auth_metrics(AUTH_MOD_JWK_TOKEN, "failure", "invalid_jwk", start_time)
169+
response = ServiceUnavailableResponse(
170+
backend_name="JWK key server",
171+
cause="Authentication keys are malformed",
172+
)
173+
raise HTTPException(**response.model_dump()) from exc
174+
175+
176+
def _decode_jwk_claims(user_token: str, jwk_set: KeySet, start_time: float) -> Any:
177+
"""Decode a JWT and record bounded auth failures."""
178+
try:
179+
return jwt.decode(user_token, key=key_resolver_func(jwk_set))
180+
except (KeyNotFoundError, BadSignatureError, DecodeError, JoseError) as exc:
181+
logger.warning("Token decode error: %s", exc)
182+
record_auth_metrics(
183+
AUTH_MOD_JWK_TOKEN, "failure", "token_decode_error", start_time
184+
)
185+
if isinstance(exc, KeyNotFoundError):
186+
cause = "Token signed by unknown key"
187+
elif isinstance(exc, BadSignatureError):
188+
cause = "Invalid token signature"
189+
elif isinstance(exc, DecodeError):
190+
cause = "Token could not be decoded"
191+
else:
192+
cause = "Token format error"
193+
response = UnauthorizedResponse(cause=cause)
194+
raise HTTPException(**response.model_dump()) from exc
195+
196+
197+
def _validate_jwk_claims(claims: Any, start_time: float) -> None:
198+
"""Validate decoded JWT claims and record bounded auth failures."""
199+
try:
200+
claims.validate()
201+
except ExpiredTokenError as exc:
202+
record_auth_metrics(AUTH_MOD_JWK_TOKEN, "failure", "token_expired", start_time)
203+
response = UnauthorizedResponse(cause="Token has expired")
204+
raise HTTPException(**response.model_dump()) from exc
205+
except JoseError as exc:
206+
record_auth_metrics(
207+
AUTH_MOD_JWK_TOKEN, "failure", "token_validation_error", start_time
208+
)
209+
response = UnauthorizedResponse(cause="Token validation failed")
210+
raise HTTPException(**response.model_dump()) from exc
211+
212+
213+
def _get_required_claim(claims: Any, claim_name: str, start_time: float) -> str:
214+
"""Return a required JWT claim and record bounded auth failures when missing."""
215+
try:
216+
value = claims[claim_name]
217+
except KeyError as exc:
218+
record_auth_metrics(AUTH_MOD_JWK_TOKEN, "failure", "missing_claim", start_time)
219+
response = UnauthorizedResponse(cause=f"Token missing claim: {claim_name}")
220+
raise HTTPException(**response.model_dump()) from exc
221+
if not isinstance(value, str) or not value.strip():
222+
record_auth_metrics(AUTH_MOD_JWK_TOKEN, "failure", "invalid_claim", start_time)
223+
response = UnauthorizedResponse(cause=f"Token has invalid claim: {claim_name}")
224+
invalid_claim_error = ValueError(
225+
f"Token claim {claim_name} must be a non-empty string"
226+
)
227+
raise HTTPException(**response.model_dump()) from invalid_claim_error
228+
return value
229+
230+
142231
class JwkTokenAuthDependency(AuthInterface): # pylint: disable=too-few-public-methods
143232
"""JWK AuthDependency class for JWK-based JWT authentication."""
144233

@@ -187,73 +276,40 @@ async def __call__(self, request: Request) -> AuthTuple:
187276
extracted from the validated JWT. Only returned on successful
188277
authentication; all error paths raise HTTPException.
189278
"""
279+
start_time = time.monotonic()
280+
190281
if not request.headers.get("Authorization"):
282+
record_auth_metrics(
283+
AUTH_MOD_JWK_TOKEN, "failure", "missing_header", start_time
284+
)
191285
response = UnauthorizedResponse(cause="No Authorization header found")
192286
raise HTTPException(**response.model_dump())
193287

194-
user_token = extract_user_token(request.headers)
195-
196-
try:
197-
jwk_set = await get_jwk_set(str(self.config.url))
198-
except aiohttp.ClientError as exc:
199-
logger.error("Failed to fetch JWK set: %s", exc)
200-
response = UnauthorizedResponse(
201-
cause="Unable to reach authentication key server"
202-
)
203-
raise HTTPException(**response.model_dump()) from exc
204-
except json.JSONDecodeError as exc:
205-
logger.error("Invalid JSON in JWK set response: %s", exc)
206-
response = UnauthorizedResponse(
207-
cause="Authentication key server returned invalid data"
208-
)
209-
raise HTTPException(**response.model_dump()) from exc
210-
except JoseError as exc:
211-
logger.error("Invalid JWK set format: %s", exc)
212-
response = UnauthorizedResponse(cause="Authentication keys are malformed")
213-
raise HTTPException(**response.model_dump()) from exc
214-
215-
try:
216-
claims = jwt.decode(user_token, key=key_resolver_func(jwk_set))
217-
except (KeyNotFoundError, BadSignatureError, DecodeError, JoseError) as exc:
218-
logger.warning("Token decode error: %s", exc)
219-
cause_map = {
220-
KeyNotFoundError: "Token signed by unknown key",
221-
BadSignatureError: "Invalid token signature",
222-
DecodeError: "Token could not be decoded",
223-
JoseError: "Token format error",
224-
}
225-
response = UnauthorizedResponse(
226-
cause=cause_map.get(type(exc), "Unknown token error")
227-
)
228-
raise HTTPException(**response.model_dump()) from exc
229-
230288
try:
231-
claims.validate()
232-
except ExpiredTokenError as exc:
233-
response = UnauthorizedResponse(cause="Token has expired")
234-
raise HTTPException(**response.model_dump()) from exc
235-
except JoseError as exc:
236-
response = UnauthorizedResponse(cause="Token validation failed")
237-
raise HTTPException(**response.model_dump()) from exc
238-
239-
try:
240-
user_id: str = claims[self.config.jwt_configuration.user_id_claim]
241-
except KeyError as exc:
242-
missing_claim = self.config.jwt_configuration.user_id_claim
243-
response = UnauthorizedResponse(
244-
cause=f"Token missing claim: {missing_claim}"
289+
user_token = extract_user_token(request.headers)
290+
except HTTPException:
291+
record_auth_metrics(
292+
AUTH_MOD_JWK_TOKEN, "failure", "missing_token", start_time
245293
)
246-
raise HTTPException(**response.model_dump()) from exc
247-
248-
try:
249-
username: str = claims[self.config.jwt_configuration.username_claim]
250-
except KeyError as exc:
251-
missing_claim = self.config.jwt_configuration.username_claim
252-
response = UnauthorizedResponse(
253-
cause=f"Token missing claim: {missing_claim}"
294+
raise
295+
except Exception: # pylint: disable=broad-exception-caught
296+
logger.exception("Unexpected error while extracting JWK bearer token")
297+
record_auth_metrics(
298+
AUTH_MOD_JWK_TOKEN, "failure", "unexpected_error", start_time
254299
)
255-
raise HTTPException(**response.model_dump()) from exc
300+
raise
301+
302+
jwk_set = await _get_jwk_set_for_auth(self.config, start_time)
303+
claims = _decode_jwk_claims(user_token, jwk_set, start_time)
304+
_validate_jwk_claims(claims, start_time)
305+
user_id = _get_required_claim(
306+
claims, self.config.jwt_configuration.user_id_claim, start_time
307+
)
308+
username = _get_required_claim(
309+
claims, self.config.jwt_configuration.username_claim, start_time
310+
)
256311

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

314+
record_auth_metrics(AUTH_MOD_JWK_TOKEN, "success", "authenticated", start_time)
259315
return user_id, username, self.skip_userid_check, user_token

0 commit comments

Comments
 (0)