Loading .pre-commit-config.yaml +1 −1 Changes for .pre-commit-config.yaml: 1 added line, 1 removed line. 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 Changes for src/open_exposure_gateway/adapters/http/keycloak_token_verifier.py: 43 added lines, 0 removed lines. 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 Changes for src/open_exposure_gateway/core/state.py: 2 added lines, 0 removed lines. Original line number Diff line number Diff line Loading @@ -6,6 +6,7 @@ from open_exposure_gateway.adapters.http.fm_client import FmClient 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 @@ -14,4 +15,5 @@ class AppState(Protocol): db_engine: AsyncEngine session_maker: async_sessionmaker[AsyncSession] qod_callback_client: QodCallbackDeliveryPort token_verifier: TokenVerifierPort fm_client: FmClient src/open_exposure_gateway/dependencies.py +5 −0 Changes for src/open_exposure_gateway/dependencies.py: 5 added lines, 0 removed lines. Original line number Diff line number Diff line Loading @@ -53,6 +53,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 @@ -85,6 +86,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 Changes for src/open_exposure_gateway/main.py: 9 added lines, 0 removed lines. 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.fm_client import FmClient from open_exposure_gateway.adapters.http.qod_callback_client import HttpQodCallbackClient from open_exposure_gateway.adapters.http.srm_client import SRMClient Loading Loading @@ -329,6 +330,13 @@ async def default_lifespan(app: FastAPI) -> AsyncGenerator[None, None]: qod_callback_client = HttpQodCallbackClient() fm_client = FmClient() 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 @@ -395,6 +403,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 app.state.fm_client = fm_client yield Loading Loading
.pre-commit-config.yaml +1 −1 Changes for .pre-commit-config.yaml: 1 added line, 1 removed line. 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 Changes for src/open_exposure_gateway/adapters/http/keycloak_token_verifier.py: 43 added lines, 0 removed lines. 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 Changes for src/open_exposure_gateway/core/state.py: 2 added lines, 0 removed lines. Original line number Diff line number Diff line Loading @@ -6,6 +6,7 @@ from open_exposure_gateway.adapters.http.fm_client import FmClient 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 @@ -14,4 +15,5 @@ class AppState(Protocol): db_engine: AsyncEngine session_maker: async_sessionmaker[AsyncSession] qod_callback_client: QodCallbackDeliveryPort token_verifier: TokenVerifierPort fm_client: FmClient
src/open_exposure_gateway/dependencies.py +5 −0 Changes for src/open_exposure_gateway/dependencies.py: 5 added lines, 0 removed lines. Original line number Diff line number Diff line Loading @@ -53,6 +53,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 @@ -85,6 +86,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 Changes for src/open_exposure_gateway/main.py: 9 added lines, 0 removed lines. 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.fm_client import FmClient from open_exposure_gateway.adapters.http.qod_callback_client import HttpQodCallbackClient from open_exposure_gateway.adapters.http.srm_client import SRMClient Loading Loading @@ -329,6 +330,13 @@ async def default_lifespan(app: FastAPI) -> AsyncGenerator[None, None]: qod_callback_client = HttpQodCallbackClient() fm_client = FmClient() 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 @@ -395,6 +403,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 app.state.fm_client = fm_client yield Loading