Commit 49308cb9 authored by George Papathanail's avatar George Papathanail
Browse files

feat: add Keycloak token verfier port and adapter

parent 4d13e965
Loading
Loading
Loading
Loading
Loading
+1 −1
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
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
Original line number Diff line number Diff line
@@ -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):
@@ -13,3 +14,4 @@ class AppState(Protocol):
    db_engine: AsyncEngine
    session_maker: async_sessionmaker[AsyncSession]
    qod_callback_client: QodCallbackDeliveryPort
    token_verifier: TokenVerifierPort
+5 −0
Original line number Diff line number Diff line
@@ -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
@@ -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

+9 −0
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.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 (
@@ -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()
@@ -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