diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..1d17dae --- /dev/null +++ b/.dockerignore @@ -0,0 +1 @@ +.venv diff --git a/compose.yml b/compose.yml index 0ad0541..7c5c064 100644 --- a/compose.yml +++ b/compose.yml @@ -183,7 +183,7 @@ services: mrok-controller: build: context: . - dockerfile: prod.Dockerfile + dockerfile: dev.Dockerfile depends_on: ziti-controller: condition: service_healthy @@ -204,13 +204,14 @@ services: - MROK_LOGGING__DEBUG=false networks: mrok: + ipv4_address: ${MROK_DEVCONTAINER_IP:-172.99.0.15} dns: - ${MROK_DNS_IP:-172.99.0.2} mrok-frontend: build: context: . - dockerfile: prod.Dockerfile + dockerfile: dev.Dockerfile depends_on: ziti-controller: condition: service_healthy diff --git a/dev.Dockerfile b/dev.Dockerfile index cbc9e94..c35b498 100644 --- a/dev.Dockerfile +++ b/dev.Dockerfile @@ -3,7 +3,7 @@ FROM python:3.12 # The uv installer requires curl (and certificates) to download the release archive RUN apt-get clean -y; \ apt-get update; \ - apt-get install -y --no-install-recommends ca-certificates curl vim postgresql-client netcat-openbsd libprotobuf-c1 dnsutils; \ + apt-get install -y --no-install-recommends ca-certificates curl vim postgresql-client netcat-openbsd libprotobuf-c1 dnsutils jq; \ apt-get autoremove --purge -y; \ apt-get clean -y; \ rm -rf /var/lib/apt/lists/* /var/cache/apt/archives/* diff --git a/mrok/conf.py b/mrok/conf.py index eccf589..30e629e 100644 --- a/mrok/conf.py +++ b/mrok/conf.py @@ -15,12 +15,6 @@ "ssl_verify": False, }, "PAGINATION": {"limit": 50}, - "SIDECAR": { - "textual_port": 4040, - "store_port": 5051, - "store_size": 1000, - "textual_command": "python mrok/agent/sidecar/inspector.py", - }, } _settings = None diff --git a/mrok/controller/app.py b/mrok/controller/app.py index eef5fae..2a2fd02 100644 --- a/mrok/controller/app.py +++ b/mrok/controller/app.py @@ -5,8 +5,8 @@ from fastapi import Depends, FastAPI from fastapi.routing import APIRoute, APIRouter -from mrok.conf import get_settings -from mrok.controller.auth import authenticate +from mrok.conf import Settings, get_settings +from mrok.controller.auth import HTTPAuthManager from mrok.controller.openapi import generate_openapi_spec from mrok.controller.routes.extensions import router as extensions_router from mrok.controller.routes.instances import router as instances_router @@ -36,7 +36,8 @@ def setup_custom_serialization(router: APIRouter): api_route.response_model_exclude_none = True -def setup_app(): +def setup_app(settings: Settings): + auth_manager = HTTPAuthManager(settings.auth) app = FastAPI( title="mrok Controller API", description="API to orchestrate OpenZiti for Extensions.", @@ -53,18 +54,16 @@ def setup_app(): app.include_router( extensions_router, prefix="/extensions", - dependencies=[Depends(authenticate)], + dependencies=[Depends(auth_manager)], ) app.include_router( instances_router, prefix="/instances", - dependencies=[Depends(authenticate)], + dependencies=[Depends(auth_manager)], ) - settings = get_settings() - - app.openapi = partial(generate_openapi_spec, app, settings) + app.openapi = partial(generate_openapi_spec, app, settings) # type: ignore[method-assign] return app -app = setup_app() +app = setup_app(get_settings()) diff --git a/mrok/controller/auth.py b/mrok/controller/auth.py deleted file mode 100644 index 65866f8..0000000 --- a/mrok/controller/auth.py +++ /dev/null @@ -1,87 +0,0 @@ -import logging -from typing import Annotated - -import httpx -import jwt -from fastapi import Depends, HTTPException, Request, status -from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer - -from mrok.controller.dependencies.conf import AppSettings - -logger = logging.getLogger("mrok.controller") - -UNAUTHORIZED_EXCEPTION = HTTPException( - status_code=status.HTTP_401_UNAUTHORIZED, detail="Unauthorized." -) - - -class JWTCredentials(HTTPAuthorizationCredentials): - pass - - -class JWTBearer(HTTPBearer): - def __init__(self): - super().__init__(auto_error=False) - - async def __call__(self, request: Request) -> JWTCredentials: - credentials = await super().__call__(request) - if not credentials: - raise UNAUTHORIZED_EXCEPTION - try: - return JWTCredentials( - scheme=credentials.scheme, - credentials=credentials.credentials, - ) - except jwt.InvalidTokenError: - raise UNAUTHORIZED_EXCEPTION - - -async def authenticate( - settings: AppSettings, - credentials: Annotated[JWTCredentials, Depends(JWTBearer())], -): - async with httpx.AsyncClient( - timeout=httpx.Timeout( - connect=0.25, - read=settings.auth.read_timeout, - write=2.0, - pool=5.0, - ), - ) as client: - try: - config_resp = await client.get(settings.auth.openid_config_url) - config_resp.raise_for_status() - config = config_resp.json() - issuer = config["issuer"] - jwks_uri = config["jwks_uri"] - - jwks_resp = await client.get(jwks_uri) - jwks_resp.raise_for_status() - jwks = jwks_resp.json() - - header = jwt.get_unverified_header(credentials.credentials) - kid = header["kid"] - - key_data = next((k for k in jwks["keys"] if k["kid"] == kid), None) - except Exception: - logger.exception("Error fetching openid-config/jwks") - raise UNAUTHORIZED_EXCEPTION - if key_data is None: - logger.error("Key ID not found in JWKS") - raise UNAUTHORIZED_EXCEPTION - - try: - payload = jwt.decode( - credentials.credentials, - jwt.PyJWK(key_data), - algorithms=[header["alg"]], - issuer=issuer, - audience=settings.auth.audience, - ) - return payload - except jwt.InvalidKeyError as e: - logger.error(f"Invalid jwt token: {e} ({credentials.credentials})") - raise UNAUTHORIZED_EXCEPTION - except jwt.InvalidTokenError as e: - logger.error(f"Invalid jwt token: {e} ({credentials.credentials})") - raise UNAUTHORIZED_EXCEPTION diff --git a/mrok/controller/auth/__init__.py b/mrok/controller/auth/__init__.py new file mode 100644 index 0000000..412a940 --- /dev/null +++ b/mrok/controller/auth/__init__.py @@ -0,0 +1,11 @@ +from mrok.controller.auth.backends import OIDCJWTAuthenticationBackend # noqa: F401 +from mrok.controller.auth.base import AuthIdentity, BaseHTTPAuthBackend +from mrok.controller.auth.manager import HTTPAuthManager +from mrok.controller.auth.registry import register_authentication_backend + +__all__ = [ + "AuthIdentity", + "BaseHTTPAuthBackend", + "HTTPAuthManager", + "register_authentication_backend", +] diff --git a/mrok/controller/auth/backends.py b/mrok/controller/auth/backends.py new file mode 100644 index 0000000..23c2583 --- /dev/null +++ b/mrok/controller/auth/backends.py @@ -0,0 +1,60 @@ +import logging + +import httpx +import jwt +from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer +from fastapi.security.http import HTTPBase + +from mrok.controller.auth.base import UNAUTHORIZED_EXCEPTION, AuthIdentity, BaseHTTPAuthBackend +from mrok.controller.auth.registry import register_authentication_backend + +logger = logging.getLogger("mrok.controller") + + +@register_authentication_backend("oidc") +class OIDCJWTAuthenticationBackend(BaseHTTPAuthBackend): + def init_scheme(self) -> HTTPBase: + return HTTPBearer(auto_error=False) + + async def authenticate(self, credentials: HTTPAuthorizationCredentials) -> AuthIdentity | None: + async with httpx.AsyncClient() as client: + try: + config_resp = await client.get(self.config.openid_config_url) + config_resp.raise_for_status() + config = config_resp.json() + issuer = config["issuer"] + jwks_uri = config["jwks_uri"] + + jwks_resp = await client.get(jwks_uri) + jwks_resp.raise_for_status() + jwks = jwks_resp.json() + + header = jwt.get_unverified_header(credentials.credentials) + kid = header["kid"] + + key_data = next((k for k in jwks["keys"] if k["kid"] == kid), None) + except Exception: + logger.exception("Error fetching openid-config/jwks") + raise UNAUTHORIZED_EXCEPTION + if key_data is None: + logger.error("Key ID not found in JWKS") + raise UNAUTHORIZED_EXCEPTION + + try: + payload = jwt.decode( + credentials.credentials, + jwt.PyJWK(key_data), + algorithms=[header["alg"]], + issuer=issuer, + audience=self.config.audience, + ) + return AuthIdentity( + subject=payload["sub"], + metadata=payload, + ) + except jwt.InvalidKeyError as e: + logger.error(f"Invalid jwt token: {e} ({credentials.credentials})") + raise UNAUTHORIZED_EXCEPTION + except jwt.InvalidTokenError as e: + logger.error(f"Invalid jwt token: {e} ({credentials.credentials})") + raise UNAUTHORIZED_EXCEPTION diff --git a/mrok/controller/auth/base.py b/mrok/controller/auth/base.py new file mode 100644 index 0000000..d657322 --- /dev/null +++ b/mrok/controller/auth/base.py @@ -0,0 +1,38 @@ +from abc import ABC, abstractmethod +from typing import Any + +from dynaconf.utils.boxing import DynaBox +from fastapi import HTTPException, Request, status +from fastapi.security import HTTPAuthorizationCredentials +from fastapi.security.http import HTTPBase +from pydantic import BaseModel + +UNAUTHORIZED_EXCEPTION = HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, detail="Unauthorized." +) + + +class AuthIdentity(BaseModel): + subject: str + scopes: list[str] = [] + metadata: dict[str, Any] = {} + + +class BaseHTTPAuthBackend(ABC): + def __init__(self, config: DynaBox): + self.config = config + self.scheme = self.init_scheme() + + @abstractmethod + def init_scheme(self) -> HTTPBase: + raise NotImplementedError() + + @abstractmethod + async def authenticate(self, credentials: HTTPAuthorizationCredentials) -> AuthIdentity | None: + raise NotImplementedError() + + async def __call__(self, request: Request) -> AuthIdentity | None: + credentials = await self.scheme(request) + if not credentials: + return None + return await self.authenticate(credentials) diff --git a/mrok/controller/auth/manager.py b/mrok/controller/auth/manager.py new file mode 100644 index 0000000..447e752 --- /dev/null +++ b/mrok/controller/auth/manager.py @@ -0,0 +1,31 @@ +from dynaconf.utils.boxing import DynaBox +from fastapi import Request + +from mrok.controller.auth.base import UNAUTHORIZED_EXCEPTION, AuthIdentity, BaseHTTPAuthBackend +from mrok.controller.auth.registry import get_authentication_backend + + +class HTTPAuthManager: + def __init__(self, auth_settings: DynaBox): + self.auth_settings = auth_settings + self.active_backends: list[BaseHTTPAuthBackend] = [] + self._setup_backends() + + def _setup_backends(self): + enabled_keys = self.auth_settings.get("backends", []) + + for key in enabled_keys: + backend_cls = get_authentication_backend(key) + if not backend_cls: + raise ValueError(f"Backend '{key}' is not registered.") + + specific_config = self.auth_settings.get(key, {}) + self.active_backends.append(backend_cls(specific_config)) + + async def __call__(self, request: Request) -> AuthIdentity: + for backend in self.active_backends: + identity = await backend(request) + if identity: + return identity + + raise UNAUTHORIZED_EXCEPTION diff --git a/mrok/controller/auth/registry.py b/mrok/controller/auth/registry.py new file mode 100644 index 0000000..bcdff67 --- /dev/null +++ b/mrok/controller/auth/registry.py @@ -0,0 +1,17 @@ +from mrok.controller.auth.base import BaseHTTPAuthBackend + +BACKEND_REGISTRY: dict[str, type[BaseHTTPAuthBackend]] = {} + + +def register_authentication_backend(name: str): + """Decorator to register a backend class with a unique key.""" + + def decorator(cls: type[BaseHTTPAuthBackend]): + BACKEND_REGISTRY[name] = cls + return cls + + return decorator + + +def get_authentication_backend(name: str) -> type[BaseHTTPAuthBackend] | None: + return BACKEND_REGISTRY.get(name) diff --git a/settings.yaml b/settings.yaml index ab33a46..ec36e90 100644 --- a/settings.yaml +++ b/settings.yaml @@ -20,9 +20,12 @@ logging: rich: True auth: - openid_config_url: https://example.com/openid-configuration - audience: my-audience - read_timeout: 10 + backends: + - oidc + oidc: + openid_config_url: https://example.com/openid-configuration + audience: my-audience + pagination: limit: 50 diff --git a/tests/conftest.py b/tests/conftest.py index ae806e5..a80b214 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -28,7 +28,6 @@ def _get_settings( pagination: dict | None = None, proxy: dict | None = None, auth: dict | None = None, - sidecar: dict | None = None, ) -> Settings: ziti = ziti or { "api": { @@ -46,21 +45,17 @@ def _get_settings( } pagination = pagination or {"limit": 5} auth = auth or { - "openid_config_url": "http://example.com/openid-configuration", - "audience": "mrok-audience", - "read_timeout": 10, + "backends": ["oidc"], + "oidc": { + "openid_config_url": "http://example.com/openid-configuration", + "audience": "mrok-audience", + }, } proxy = proxy or { "identity": "public", "mode": "zrok", "domain": "exts.s1.today", } - sidecar = sidecar or { - "textual_port": 4040, - "store_port": 5051, - "store_size": 1000, - "textual_command": "python mrok/agent/sidecar/inspector.py", - } settings = Dynaconf( environments=True, settings_files=[], @@ -70,7 +65,6 @@ def _get_settings( PAGINATION=pagination, PROXY=proxy, AUTH=auth, - SIDECAR=sidecar, ) return settings @@ -203,7 +197,7 @@ def fastapi_app(settings_factory: SettingsFactory) -> FastAPI: settings = settings_factory() from mrok.controller.app import setup_app - app = setup_app() + app = setup_app(settings) app.dependency_overrides[get_settings] = lambda: settings return app @@ -227,7 +221,7 @@ async def api_client( settings = settings_factory() httpx_mock.add_response( method="GET", - url=settings.auth.openid_config_url, + url=settings.auth.oidc.openid_config_url, json=openid_config, is_reusable=True, ) diff --git a/tests/controller/test_auth.py b/tests/controller/test_auth.py index 55a3827..f883d60 100644 --- a/tests/controller/test_auth.py +++ b/tests/controller/test_auth.py @@ -49,7 +49,7 @@ async def test_invalid_openid_config_url( settings = settings_factory() httpx_mock.add_response( method="GET", - url=settings.auth.openid_config_url, + url=settings.auth.oidc.openid_config_url, status_code=404, ) async with AsyncClient( @@ -71,7 +71,7 @@ async def test_invalid_openid_config_data( settings = settings_factory() httpx_mock.add_response( method="GET", - url=settings.auth.openid_config_url, + url=settings.auth.oidc.openid_config_url, json={}, ) async with AsyncClient( @@ -94,7 +94,7 @@ async def test_invalid_jwks_url( settings = settings_factory() httpx_mock.add_response( method="GET", - url=settings.auth.openid_config_url, + url=settings.auth.oidc.openid_config_url, json=openid_config, ) httpx_mock.add_response( @@ -122,7 +122,7 @@ async def test_invalid_jwks_data( settings = settings_factory() httpx_mock.add_response( method="GET", - url=settings.auth.openid_config_url, + url=settings.auth.oidc.openid_config_url, json=openid_config, ) httpx_mock.add_response(method="GET", url=openid_config["jwks_uri"], status_code=200, json={}) @@ -146,7 +146,7 @@ async def test_jwks_key_not_found( settings = settings_factory() httpx_mock.add_response( method="GET", - url=settings.auth.openid_config_url, + url=settings.auth.oidc.openid_config_url, json=openid_config, ) httpx_mock.add_response( @@ -166,6 +166,6 @@ async def test_invalid_key_or_token_error( mocker: MockerFixture, exc_type: type[Exception], ): - mocker.patch("mrok.controller.auth.jwt.decode", side_effect=exc_type("bla")) + mocker.patch("mrok.controller.auth.backends.jwt.decode", side_effect=exc_type("bla")) resp = await api_client.get("/extensions") assert resp.status_code == 401