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

feat: wire get_caller_context to verify real Keycloak tokens

parent 49308cb9
Loading
Loading
Loading
Loading
Loading
+8 −2
Original line number Diff line number Diff line
@@ -50,10 +50,16 @@ class ObservabilitySettings(BaseModel):


class KeycloakSettings(BaseModel):
    issuer: str = "http://localhost:8080/realms/oeg"
    issuer: str = "http://localhost:8080/realms/oop"
    audience: str = "oeg"
    jwks_url: HttpUrl = HttpUrl("http://localhost:8080/realms/oeg/protocol/openid-connect/certs")
    jwks_url: HttpUrl = HttpUrl("http://localhost:8080/realms/oop/protocol/openid-connect/certs")
    algorithms: list[str] = ["RS256"]
    # Realm-specific claim names -- kept as config, not literals in dependencies.py,
    # since the realm/protocol-mapper names are owned by a separate Helm MR and can
    # still change. Defaults match that MR's current `organization` user-attribute
    # mapper and the standard OIDC `azp` (authorized party / calling client id).
    tenant_id_claim: str = "organization"
    app_provider_id_claim: str = "azp"


class CallbackSettings(BaseModel):
+29 −11
Original line number Diff line number Diff line
@@ -3,6 +3,7 @@ from dataclasses import dataclass
from typing import Annotated, Optional, cast

from fastapi import Depends, Request
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
from sqlalchemy import text
from sqlalchemy.ext.asyncio import AsyncEngine, AsyncSession

@@ -39,6 +40,7 @@ from open_exposure_gateway.application.services.quality_on_demand_service import
    QualityOnDemandService,
)
from open_exposure_gateway.core.config import get_settings
from open_exposure_gateway.core.exceptions import UnauthorizedException
from open_exposure_gateway.core.state import AppState
from open_exposure_gateway.ports.database.callbacks import (
    CallbackDeliveryRepository,
@@ -62,17 +64,6 @@ class CallerContext:
    app_provider_id: str


def get_caller_context(
    request: Request,
    x_correlator: XCorrelatorHeader = None,
) -> CallerContext:
    return CallerContext(
        x_correlator=x_correlator_header(request),
        tenant_id="placeholder",  # TODO: extract from JWT
        app_provider_id="placeholder",  # TODO: extract from JWT
    )


def get_app_state(request: Request) -> AppState:
    return cast(AppState, request.app.state)

@@ -89,6 +80,33 @@ def get_token_verifier(request: Request) -> TokenVerifierPort:
    return get_app_state(request=request).token_verifier


_bearer_scheme = HTTPBearer(auto_error=False)


async def get_caller_context(
    request: Request,
    x_correlator: XCorrelatorHeader = None,
    credentials: Optional[HTTPAuthorizationCredentials] = Depends(_bearer_scheme),
    token_verifier: TokenVerifierPort = Depends(get_token_verifier),
) -> CallerContext:
    if credentials is None:
        raise UnauthorizedException(message="Missing bearer token")

    claims = await token_verifier.verify(credentials.credentials)

    keycloak_settings = get_settings().keycloak_settings
    tenant_id = claims.get(keycloak_settings.tenant_id_claim)
    app_provider_id = claims.get(keycloak_settings.app_provider_id_claim)
    if not tenant_id or not app_provider_id:
        raise UnauthorizedException(message="Access token missing required claims")

    return CallerContext(
        x_correlator=x_correlator_header(request),
        tenant_id=tenant_id,
        app_provider_id=app_provider_id,
    )


def get_db_engine(request: Request) -> AsyncEngine:
    return get_app_state(request=request).db_engine

+15 −0
Original line number Diff line number Diff line
@@ -3,11 +3,13 @@ from collections.abc import Callable, Generator
from typing import Any

import pytest
from fastapi import Request
from fastapi.testclient import TestClient

from open_exposure_gateway.api.camara.edge_application_management.v0_1_0_alpha_1.router import (
    BASE_PATH as EAM_BASE,
)
from open_exposure_gateway.api.error_handlers import x_correlator_header
from open_exposure_gateway.application.services.edge_application_management_service import (
    EdgeApplicationManagementService,
)
@@ -18,6 +20,8 @@ from open_exposure_gateway.application.services.quality_on_demand_service import
    QualityOnDemandService,
)
from open_exposure_gateway.dependencies import (
    CallerContext,
    get_caller_context,
    get_database_health,
    get_edge_app_service,
    get_location_retrieval_service,
@@ -43,6 +47,16 @@ from tests.unit.fakes import (
)


def fake_caller_context(request: Request) -> CallerContext:
    """Override for get_caller_context: skips real JWT verification but keeps the
    x-correlator resolution real, since several tests assert on its propagation."""
    return CallerContext(
        x_correlator=x_correlator_header(request),
        tenant_id="test_tenant",
        app_provider_id="test_app_provider",
    )


class LoggingTestClient(TestClient):
    """TestClient that prints each request/response; visible with pytest -s."""

@@ -268,6 +282,7 @@ def api_client(
    app.dependency_overrides[get_location_retrieval_service] = lambda: location_retrieval_service
    app.dependency_overrides[get_publisher] = lambda: fake_bus
    app.dependency_overrides[get_database_health] = lambda: True
    app.dependency_overrides[get_caller_context] = fake_caller_context
    # raise_server_exceptions=False: unhandled errors surface as the 500 envelope
    # a real client would see, so flow tests assert status codes, not tracebacks.
    yield LoggingTestClient(app, raise_server_exceptions=False)
+3 −1
Original line number Diff line number Diff line
@@ -22,8 +22,9 @@ from open_exposure_gateway.application.services.edge_application_management_serv
    EdgeApplicationManagementService,
)
from open_exposure_gateway.core.exceptions import NotFoundException
from open_exposure_gateway.dependencies import get_edge_app_service
from open_exposure_gateway.dependencies import get_caller_context, get_edge_app_service
from open_exposure_gateway.main import app
from tests.unit.conftest import fake_caller_context

_ZONE_ID = uuid4()
_APP_ID = uuid4()
@@ -92,6 +93,7 @@ def mock_eam_service() -> AsyncMock:
@pytest.fixture()
def client(mock_eam_service: AsyncMock) -> Generator[TestClient, None, None]:
    app.dependency_overrides[get_edge_app_service] = lambda: mock_eam_service
    app.dependency_overrides[get_caller_context] = fake_caller_context
    yield TestClient(app)
    app.dependency_overrides.clear()

+1 −1
Original line number Diff line number Diff line
@@ -108,7 +108,7 @@ class TestQueryTranslation:

        query = located_device.location_queries[0]
        assert query.target.device.phone_number == PHONE
        assert query.app_provider_id == "placeholder"
        assert query.app_provider_id == "test_app_provider"
        assert query.service_specification_id

    def test_max_age_zero_is_forwarded_not_dropped(