Commit 38324376 authored by George Papathanail's avatar George Papathanail
Browse files

feat: add Keycloak token verfier port and adapter

parent a80782b1
Loading
Loading
Loading
Loading
+1 −1
Changes for .pre-commit-config.yaml: 1 added line, 1 removed line.
Original line number Diff line number Diff line
@@ -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"]
+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
+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
@@ -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):
@@ -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
+5 −0
Changes for src/open_exposure_gateway/dependencies.py: 5 added lines, 0 removed lines.
Original line number Diff line number Diff line
@@ -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
@@ -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

+9 −0
Changes for src/open_exposure_gateway/main.py: 9 added lines, 0 removed lines.
Original line number Diff line number Diff line
@@ -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
@@ -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()
@@ -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