Loading .pre-commit-config.yaml +1 −1 Original line number Diff line number Diff line Loading @@ -23,4 +23,4 @@ repos: - id: mypy files: ^src/open_exposure_gateway/|^tests/ args: [--strict, --ignore-missing-imports, --cache-dir, .cache/mypy] additional_dependencies: ["fastapi[standard]>=0.135.1", "pydantic>=2.0", "pydantic-settings>=2.0", "httpx>=0.27", "pytest>=9.0.2", "sqlalchemy>=2.0.48", "pytest-asyncio>=0.24", "testcontainers>=4.0.0", "types-PyYAML>=6.0.1"] additional_dependencies: ["fastapi[standard]>=0.135.1", "pydantic>=2.0", "pydantic-settings>=2.0", "httpx>=0.27", "pytest>=9.0.2", "sqlalchemy>=2.0.48", "pytest-asyncio>=0.24", "testcontainers>=4.0.0", "types-PyYAML>=6.0.1", "pyjwt[crypto]>=2.10.1"] src/open_exposure_gateway/adapters/http/keycloak_token_verifier.py 0 → 100644 +43 −0 Original line number Diff line number Diff line import asyncio from typing import Any import jwt import structlog from jwt import PyJWKClient from jwt.exceptions import PyJWKClientError from open_exposure_gateway.core.config import get_settings from open_exposure_gateway.core.exceptions import DownstreamServiceException, UnauthorizedException logger = structlog.get_logger(__name__) class KeycloakTokenVerifier: def __init__(self) -> None: settings = get_settings().keycloak_settings self._issuer = settings.issuer self._audience = settings.audience self._algorithms = settings.algorithms self._jwks_client = PyJWKClient(str(settings.jwks_url)) async def verify(self, token: str) -> dict[str, Any]: try: # get_signing_key_from_jwt fetches/caches Keycloak's JWKS over HTTP via # urllib, which is blocking -- push it to a thread so it doesn't stall # the event loop the way the rest of this codebase's httpx calls don't. signing_key = await asyncio.to_thread(self._jwks_client.get_signing_key_from_jwt, token) except PyJWKClientError as exc: logger.error("keycloak_jwks_fetch_failed", error=str(exc)) raise DownstreamServiceException(message="Could not reach Keycloak") from exc try: return jwt.decode( token, signing_key.key, algorithms=self._algorithms, audience=self._audience, issuer=self._issuer, ) except jwt.PyJWTError as exc: logger.warning("invalid_access_token", error=str(exc)) raise UnauthorizedException(message="Invalid access token") from exc src/open_exposure_gateway/core/state.py +2 −0 Original line number Diff line number Diff line Loading @@ -5,6 +5,7 @@ from sqlalchemy.ext.asyncio import AsyncEngine, AsyncSession, async_sessionmaker from open_exposure_gateway.ports.databus_port import DataBusPort from open_exposure_gateway.ports.qod_callback_port import QodCallbackDeliveryPort from open_exposure_gateway.ports.srm_port import SRMClientPort from open_exposure_gateway.ports.token_verifier_port import TokenVerifierPort class AppState(Protocol): Loading @@ -13,3 +14,4 @@ class AppState(Protocol): db_engine: AsyncEngine session_maker: async_sessionmaker[AsyncSession] qod_callback_client: QodCallbackDeliveryPort token_verifier: TokenVerifierPort src/open_exposure_gateway/dependencies.py +5 −0 Original line number Diff line number Diff line Loading @@ -52,6 +52,7 @@ from open_exposure_gateway.ports.database.registration import AppRegistrationRep from open_exposure_gateway.ports.databus_port import DataBusPort from open_exposure_gateway.ports.qod_callback_port import QodCallbackDeliveryPort from open_exposure_gateway.ports.srm_port import SRMClientPort from open_exposure_gateway.ports.token_verifier_port import TokenVerifierPort @dataclass Loading Loading @@ -84,6 +85,10 @@ def get_publisher(request: Request) -> DataBusPort: return get_app_state(request=request).publisher def get_token_verifier(request: Request) -> TokenVerifierPort: return get_app_state(request=request).token_verifier def get_db_engine(request: Request) -> AsyncEngine: return get_app_state(request=request).db_engine Loading src/open_exposure_gateway/main.py +9 −0 Original line number Diff line number Diff line Loading @@ -32,6 +32,7 @@ from open_exposure_gateway.adapters.databus.nats_adapter import ( NatsOperationStatusConsumer, ) from open_exposure_gateway.adapters.http.callback_client import HttpCallbackClient from open_exposure_gateway.adapters.http.keycloak_token_verifier import KeycloakTokenVerifier from open_exposure_gateway.adapters.http.qod_callback_client import HttpQodCallbackClient from open_exposure_gateway.adapters.http.srm_client import SRMClient from open_exposure_gateway.api.camara.edge_application_management.v0_1_0_alpha_1.router import ( Loading Loading @@ -316,6 +317,13 @@ async def default_lifespan(app: FastAPI) -> AsyncGenerator[None, None]: qod_callback_client = HttpQodCallbackClient() try: token_verifier = KeycloakTokenVerifier() logger.info("Keycloak token verifier initialized") except Exception as e: logger.error("Failed to initialize Keycloak token verifier", error=str(e)) raise try: publisher = NatsMessagePublisher(settings.nats_settings) await publisher.connect() Loading Loading @@ -382,6 +390,7 @@ async def default_lifespan(app: FastAPI) -> AsyncGenerator[None, None]: app.state.db_engine = db_engine app.state.session_maker = session_maker app.state.qod_callback_client = qod_callback_client app.state.token_verifier = token_verifier yield Loading Loading
.pre-commit-config.yaml +1 −1 Original line number Diff line number Diff line Loading @@ -23,4 +23,4 @@ repos: - id: mypy files: ^src/open_exposure_gateway/|^tests/ args: [--strict, --ignore-missing-imports, --cache-dir, .cache/mypy] additional_dependencies: ["fastapi[standard]>=0.135.1", "pydantic>=2.0", "pydantic-settings>=2.0", "httpx>=0.27", "pytest>=9.0.2", "sqlalchemy>=2.0.48", "pytest-asyncio>=0.24", "testcontainers>=4.0.0", "types-PyYAML>=6.0.1"] additional_dependencies: ["fastapi[standard]>=0.135.1", "pydantic>=2.0", "pydantic-settings>=2.0", "httpx>=0.27", "pytest>=9.0.2", "sqlalchemy>=2.0.48", "pytest-asyncio>=0.24", "testcontainers>=4.0.0", "types-PyYAML>=6.0.1", "pyjwt[crypto]>=2.10.1"]
src/open_exposure_gateway/adapters/http/keycloak_token_verifier.py 0 → 100644 +43 −0 Original line number Diff line number Diff line import asyncio from typing import Any import jwt import structlog from jwt import PyJWKClient from jwt.exceptions import PyJWKClientError from open_exposure_gateway.core.config import get_settings from open_exposure_gateway.core.exceptions import DownstreamServiceException, UnauthorizedException logger = structlog.get_logger(__name__) class KeycloakTokenVerifier: def __init__(self) -> None: settings = get_settings().keycloak_settings self._issuer = settings.issuer self._audience = settings.audience self._algorithms = settings.algorithms self._jwks_client = PyJWKClient(str(settings.jwks_url)) async def verify(self, token: str) -> dict[str, Any]: try: # get_signing_key_from_jwt fetches/caches Keycloak's JWKS over HTTP via # urllib, which is blocking -- push it to a thread so it doesn't stall # the event loop the way the rest of this codebase's httpx calls don't. signing_key = await asyncio.to_thread(self._jwks_client.get_signing_key_from_jwt, token) except PyJWKClientError as exc: logger.error("keycloak_jwks_fetch_failed", error=str(exc)) raise DownstreamServiceException(message="Could not reach Keycloak") from exc try: return jwt.decode( token, signing_key.key, algorithms=self._algorithms, audience=self._audience, issuer=self._issuer, ) except jwt.PyJWTError as exc: logger.warning("invalid_access_token", error=str(exc)) raise UnauthorizedException(message="Invalid access token") from exc
src/open_exposure_gateway/core/state.py +2 −0 Original line number Diff line number Diff line Loading @@ -5,6 +5,7 @@ from sqlalchemy.ext.asyncio import AsyncEngine, AsyncSession, async_sessionmaker from open_exposure_gateway.ports.databus_port import DataBusPort from open_exposure_gateway.ports.qod_callback_port import QodCallbackDeliveryPort from open_exposure_gateway.ports.srm_port import SRMClientPort from open_exposure_gateway.ports.token_verifier_port import TokenVerifierPort class AppState(Protocol): Loading @@ -13,3 +14,4 @@ class AppState(Protocol): db_engine: AsyncEngine session_maker: async_sessionmaker[AsyncSession] qod_callback_client: QodCallbackDeliveryPort token_verifier: TokenVerifierPort
src/open_exposure_gateway/dependencies.py +5 −0 Original line number Diff line number Diff line Loading @@ -52,6 +52,7 @@ from open_exposure_gateway.ports.database.registration import AppRegistrationRep from open_exposure_gateway.ports.databus_port import DataBusPort from open_exposure_gateway.ports.qod_callback_port import QodCallbackDeliveryPort from open_exposure_gateway.ports.srm_port import SRMClientPort from open_exposure_gateway.ports.token_verifier_port import TokenVerifierPort @dataclass Loading Loading @@ -84,6 +85,10 @@ def get_publisher(request: Request) -> DataBusPort: return get_app_state(request=request).publisher def get_token_verifier(request: Request) -> TokenVerifierPort: return get_app_state(request=request).token_verifier def get_db_engine(request: Request) -> AsyncEngine: return get_app_state(request=request).db_engine Loading
src/open_exposure_gateway/main.py +9 −0 Original line number Diff line number Diff line Loading @@ -32,6 +32,7 @@ from open_exposure_gateway.adapters.databus.nats_adapter import ( NatsOperationStatusConsumer, ) from open_exposure_gateway.adapters.http.callback_client import HttpCallbackClient from open_exposure_gateway.adapters.http.keycloak_token_verifier import KeycloakTokenVerifier from open_exposure_gateway.adapters.http.qod_callback_client import HttpQodCallbackClient from open_exposure_gateway.adapters.http.srm_client import SRMClient from open_exposure_gateway.api.camara.edge_application_management.v0_1_0_alpha_1.router import ( Loading Loading @@ -316,6 +317,13 @@ async def default_lifespan(app: FastAPI) -> AsyncGenerator[None, None]: qod_callback_client = HttpQodCallbackClient() try: token_verifier = KeycloakTokenVerifier() logger.info("Keycloak token verifier initialized") except Exception as e: logger.error("Failed to initialize Keycloak token verifier", error=str(e)) raise try: publisher = NatsMessagePublisher(settings.nats_settings) await publisher.connect() Loading Loading @@ -382,6 +390,7 @@ async def default_lifespan(app: FastAPI) -> AsyncGenerator[None, None]: app.state.db_engine = db_engine app.state.session_maker = session_maker app.state.qod_callback_client = qod_callback_client app.state.token_verifier = token_verifier yield Loading