Loading src/open_exposure_gateway/core/auth/jwks_cache.py +9 −3 Changes for src/open_exposure_gateway/core/auth/jwks_cache.py: 9 added lines, 3 removed lines. Original line number Diff line number Diff line Loading @@ -2,7 +2,7 @@ import asyncio from typing import Any from jwt import PyJWKClient from jwt.exceptions import PyJWKClientError from jwt.exceptions import PyJWKClientConnectionError, PyJWKClientError class JWKSCache: Loading @@ -24,7 +24,7 @@ class JWKSCache: request, so /readyz reflects real JWKS health immediately.""" try: await asyncio.to_thread(self._client.get_jwk_set) except PyJWKClientError: except PyJWKClientConnectionError: self._healthy = False return self._healthy = True Loading @@ -32,8 +32,14 @@ class JWKSCache: async def get_signing_key(self, token: str) -> Any: try: key = await asyncio.to_thread(self._client.get_signing_key_from_jwt, token) except PyJWKClientError: except PyJWKClientConnectionError: self._healthy = False raise except PyJWKClientError: # Reached Keycloak fine -- the token just doesn't match anything (e.g. # unknown kid). Not the same failure mode as being unreachable, so # readiness shouldn't flip on it. self._healthy = True raise self._healthy = True return key src/open_exposure_gateway/core/auth/tokens.py +14 −2 Changes for src/open_exposure_gateway/core/auth/tokens.py: 14 added lines, 2 removed lines. Original line number Diff line number Diff line Loading @@ -5,11 +5,11 @@ from typing import Any import jwt import structlog from jwt.exceptions import PyJWTError from jwt.exceptions import PyJWKClientConnectionError, PyJWTError from open_exposure_gateway.core.auth.jwks_cache import JWKSCache from open_exposure_gateway.core.config import KeycloakSettings from open_exposure_gateway.core.exceptions import UnauthorizedException from open_exposure_gateway.core.exceptions import DownstreamServiceException, UnauthorizedException logger = structlog.get_logger(__name__) Loading Loading @@ -41,6 +41,18 @@ async def validate_token( ) -> DecodedClaims: try: signing_key = await jwks_cache.get_signing_key(token) except PyJWKClientConnectionError as exc: # Keycloak itself is unreachable -- the caller did nothing wrong, so this # is a 503, not a 401. Caught separately from the PyJWTError branch below, # which means the token itself is what's bad (unknown kid, bad signature, # expired, wrong issuer/audience). logger.warning("jwks_unreachable", error=str(exc)) raise DownstreamServiceException(message="Unable to reach the identity provider") from exc except PyJWTError as exc: logger.warning("invalid_access_token", error=str(exc)) raise UnauthorizedException(message="Invalid access token") from exc try: claims = jwt.decode( token, signing_key.key, Loading src/open_exposure_gateway/dependencies.py +6 −1 Changes for src/open_exposure_gateway/dependencies.py: 6 added lines, 1 removed line. Original line number Diff line number Diff line Loading @@ -43,7 +43,7 @@ from open_exposure_gateway.application.services.quality_on_demand_service import ) from open_exposure_gateway.core.auth.tokens import TokenFlow from open_exposure_gateway.core.config import Settings, get_settings from open_exposure_gateway.core.exceptions import UnauthorizedException from open_exposure_gateway.core.exceptions import DownstreamServiceException, UnauthorizedException from open_exposure_gateway.core.state import AppState from open_exposure_gateway.ports.database.callbacks import ( CallbackDeliveryRepository, Loading Loading @@ -136,6 +136,11 @@ async def get_caller_context( raise logger.warning("auth_would_reject", reason="invalid_token", path=request.url.path) return _unverified_caller_context(x_correlator_header(request)) except DownstreamServiceException: if enforcement_enabled: raise logger.warning("auth_would_reject", reason="jwks_unreachable", path=request.url.path) return _unverified_caller_context(x_correlator_header(request)) claims = decoded.claims tenant_id = claims.get("organization") Loading tests/unit/test_core_auth.py +32 −1 Changes for tests/unit/test_core_auth.py: 32 added lines, 1 removed line. Original line number Diff line number Diff line Loading @@ -14,11 +14,12 @@ import pytest from cryptography.hazmat.primitives.asymmetric import rsa from cryptography.hazmat.primitives.asymmetric.rsa import RSAPrivateKey from jwt.algorithms import RSAAlgorithm from jwt.exceptions import PyJWKClientConnectionError, PyJWKClientError from open_exposure_gateway.core.auth.jwks_cache import JWKSCache from open_exposure_gateway.core.auth.tokens import TokenFlow, validate_token from open_exposure_gateway.core.config import KeycloakSettings from open_exposure_gateway.core.exceptions import UnauthorizedException from open_exposure_gateway.core.exceptions import DownstreamServiceException, UnauthorizedException _ISSUER = "https://keycloak.test/realms/oop" _AUDIENCE = "oeg" Loading Loading @@ -144,6 +145,36 @@ class TestValidateToken: with pytest.raises(UnauthorizedException): await validate_token(token, JWKSCache(jwks_url), _settings()) async def test_jwks_unreachable_is_a_503_not_a_401( self, rsa_private_key: RSAPrivateKey ) -> None: # Nothing listens on this port: a connection failure, not a bad token -- # the caller did nothing wrong, so this must surface as 503, not 401. unreachable_cache = JWKSCache("http://localhost:1/certs") token = _make_token(rsa_private_key) with pytest.raises(DownstreamServiceException): await validate_token(token, unreachable_cache, _settings()) class TestJWKSCacheHealth: async def test_connection_failure_marks_unhealthy(self, rsa_private_key: RSAPrivateKey) -> None: cache = JWKSCache("http://localhost:1/certs") with pytest.raises(PyJWKClientConnectionError): await cache.get_signing_key(_make_token(rsa_private_key)) assert cache.is_healthy is False async def test_unknown_kid_does_not_mark_unhealthy( self, rsa_private_key: RSAPrivateKey, jwks_url: str ) -> None: # Keycloak answered fine; the token just doesn't match any published # key. That's a bad-token problem, not an outage -- readiness must not # flip on it. cache = JWKSCache(jwks_url) token = _make_token(rsa_private_key, kid="unknown-kid") with pytest.raises(PyJWKClientError): await cache.get_signing_key(token) assert cache.is_healthy is True class TestTokenFlowClassification: async def test_token_with_auth_time_is_three_legged( Loading Loading
src/open_exposure_gateway/core/auth/jwks_cache.py +9 −3 Changes for src/open_exposure_gateway/core/auth/jwks_cache.py: 9 added lines, 3 removed lines. Original line number Diff line number Diff line Loading @@ -2,7 +2,7 @@ import asyncio from typing import Any from jwt import PyJWKClient from jwt.exceptions import PyJWKClientError from jwt.exceptions import PyJWKClientConnectionError, PyJWKClientError class JWKSCache: Loading @@ -24,7 +24,7 @@ class JWKSCache: request, so /readyz reflects real JWKS health immediately.""" try: await asyncio.to_thread(self._client.get_jwk_set) except PyJWKClientError: except PyJWKClientConnectionError: self._healthy = False return self._healthy = True Loading @@ -32,8 +32,14 @@ class JWKSCache: async def get_signing_key(self, token: str) -> Any: try: key = await asyncio.to_thread(self._client.get_signing_key_from_jwt, token) except PyJWKClientError: except PyJWKClientConnectionError: self._healthy = False raise except PyJWKClientError: # Reached Keycloak fine -- the token just doesn't match anything (e.g. # unknown kid). Not the same failure mode as being unreachable, so # readiness shouldn't flip on it. self._healthy = True raise self._healthy = True return key
src/open_exposure_gateway/core/auth/tokens.py +14 −2 Changes for src/open_exposure_gateway/core/auth/tokens.py: 14 added lines, 2 removed lines. Original line number Diff line number Diff line Loading @@ -5,11 +5,11 @@ from typing import Any import jwt import structlog from jwt.exceptions import PyJWTError from jwt.exceptions import PyJWKClientConnectionError, PyJWTError from open_exposure_gateway.core.auth.jwks_cache import JWKSCache from open_exposure_gateway.core.config import KeycloakSettings from open_exposure_gateway.core.exceptions import UnauthorizedException from open_exposure_gateway.core.exceptions import DownstreamServiceException, UnauthorizedException logger = structlog.get_logger(__name__) Loading Loading @@ -41,6 +41,18 @@ async def validate_token( ) -> DecodedClaims: try: signing_key = await jwks_cache.get_signing_key(token) except PyJWKClientConnectionError as exc: # Keycloak itself is unreachable -- the caller did nothing wrong, so this # is a 503, not a 401. Caught separately from the PyJWTError branch below, # which means the token itself is what's bad (unknown kid, bad signature, # expired, wrong issuer/audience). logger.warning("jwks_unreachable", error=str(exc)) raise DownstreamServiceException(message="Unable to reach the identity provider") from exc except PyJWTError as exc: logger.warning("invalid_access_token", error=str(exc)) raise UnauthorizedException(message="Invalid access token") from exc try: claims = jwt.decode( token, signing_key.key, Loading
src/open_exposure_gateway/dependencies.py +6 −1 Changes for src/open_exposure_gateway/dependencies.py: 6 added lines, 1 removed line. Original line number Diff line number Diff line Loading @@ -43,7 +43,7 @@ from open_exposure_gateway.application.services.quality_on_demand_service import ) from open_exposure_gateway.core.auth.tokens import TokenFlow from open_exposure_gateway.core.config import Settings, get_settings from open_exposure_gateway.core.exceptions import UnauthorizedException from open_exposure_gateway.core.exceptions import DownstreamServiceException, UnauthorizedException from open_exposure_gateway.core.state import AppState from open_exposure_gateway.ports.database.callbacks import ( CallbackDeliveryRepository, Loading Loading @@ -136,6 +136,11 @@ async def get_caller_context( raise logger.warning("auth_would_reject", reason="invalid_token", path=request.url.path) return _unverified_caller_context(x_correlator_header(request)) except DownstreamServiceException: if enforcement_enabled: raise logger.warning("auth_would_reject", reason="jwks_unreachable", path=request.url.path) return _unverified_caller_context(x_correlator_header(request)) claims = decoded.claims tenant_id = claims.get("organization") Loading
tests/unit/test_core_auth.py +32 −1 Changes for tests/unit/test_core_auth.py: 32 added lines, 1 removed line. Original line number Diff line number Diff line Loading @@ -14,11 +14,12 @@ import pytest from cryptography.hazmat.primitives.asymmetric import rsa from cryptography.hazmat.primitives.asymmetric.rsa import RSAPrivateKey from jwt.algorithms import RSAAlgorithm from jwt.exceptions import PyJWKClientConnectionError, PyJWKClientError from open_exposure_gateway.core.auth.jwks_cache import JWKSCache from open_exposure_gateway.core.auth.tokens import TokenFlow, validate_token from open_exposure_gateway.core.config import KeycloakSettings from open_exposure_gateway.core.exceptions import UnauthorizedException from open_exposure_gateway.core.exceptions import DownstreamServiceException, UnauthorizedException _ISSUER = "https://keycloak.test/realms/oop" _AUDIENCE = "oeg" Loading Loading @@ -144,6 +145,36 @@ class TestValidateToken: with pytest.raises(UnauthorizedException): await validate_token(token, JWKSCache(jwks_url), _settings()) async def test_jwks_unreachable_is_a_503_not_a_401( self, rsa_private_key: RSAPrivateKey ) -> None: # Nothing listens on this port: a connection failure, not a bad token -- # the caller did nothing wrong, so this must surface as 503, not 401. unreachable_cache = JWKSCache("http://localhost:1/certs") token = _make_token(rsa_private_key) with pytest.raises(DownstreamServiceException): await validate_token(token, unreachable_cache, _settings()) class TestJWKSCacheHealth: async def test_connection_failure_marks_unhealthy(self, rsa_private_key: RSAPrivateKey) -> None: cache = JWKSCache("http://localhost:1/certs") with pytest.raises(PyJWKClientConnectionError): await cache.get_signing_key(_make_token(rsa_private_key)) assert cache.is_healthy is False async def test_unknown_kid_does_not_mark_unhealthy( self, rsa_private_key: RSAPrivateKey, jwks_url: str ) -> None: # Keycloak answered fine; the token just doesn't match any published # key. That's a bad-token problem, not an outage -- readiness must not # flip on it. cache = JWKSCache(jwks_url) token = _make_token(rsa_private_key, kid="unknown-kid") with pytest.raises(PyJWKClientError): await cache.get_signing_key(token) assert cache.is_healthy is True class TestTokenFlowClassification: async def test_token_with_auth_time_is_three_legged( Loading