Commit 393c9cb0 authored by George Papathanail's avatar George Papathanail
Browse files

fix: return 503 instead of 401 when keycloak's jwks endpoint is unreachable

parent 550ea468
Loading
Loading
Loading
Loading
+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
@@ -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:
@@ -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
@@ -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
+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
@@ -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__)

@@ -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,
+6 −1
Changes for src/open_exposure_gateway/dependencies.py: 6 added lines, 1 removed line.
Original line number Diff line number Diff line
@@ -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,
@@ -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")
+32 −1
Changes for tests/unit/test_core_auth.py: 32 added lines, 1 removed line.
Original line number Diff line number Diff line
@@ -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"
@@ -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(