From 96f90373dc289270f0329693f050d69e27dc5060 Mon Sep 17 00:00:00 2001 From: Jens Date: Sun, 30 Aug 2026 05:59:49 +0200 Subject: [PATCH] feat(auth): harden Authentik and guest capability boundaries --- backend/app/api/routes/auth.py | 92 ++++++- backend/app/core/config.py | 66 ++++- backend/app/main.py | 10 +- backend/app/schemas/auth.py | 1 + .../app/services/authentik_oidc_service.py | 207 +++++++++++++++ backend/tests/test_auth.py | 237 +++++++++++++++++- backend/tests/test_authentik_oidc_service.py | 183 ++++++++++++++ backend/tests/test_request_target_security.py | 23 +- frontend/src/App.tsx | 1 + .../src/components/auth/LandingPage.test.tsx | 11 + frontend/src/components/auth/LandingPage.tsx | 23 ++ frontend/src/hooks/useOperatorSession.ts | 2 + frontend/src/lib/accessCapabilities.test.ts | 34 +++ frontend/src/lib/accessCapabilities.ts | 45 ++++ frontend/src/services/api/auth.ts | 1 + 15 files changed, 928 insertions(+), 8 deletions(-) create mode 100644 backend/app/services/authentik_oidc_service.py create mode 100644 backend/tests/test_authentik_oidc_service.py create mode 100644 frontend/src/lib/accessCapabilities.test.ts create mode 100644 frontend/src/lib/accessCapabilities.ts diff --git a/backend/app/api/routes/auth.py b/backend/app/api/routes/auth.py index 13db641d..d3cd7bef 100644 --- a/backend/app/api/routes/auth.py +++ b/backend/app/api/routes/auth.py @@ -1,8 +1,10 @@ from __future__ import annotations +import logging from datetime import UTC, datetime from fastapi import APIRouter, Depends, Request, Response, status +from fastapi.responses import RedirectResponse from sqlalchemy.orm import Session from app.core.config import get_settings @@ -10,14 +12,22 @@ from app.core.errors import AppError from app.db.session import get_db from app.schemas.auth import AuthLoginRequest, AuthSession, AuthSessionEnvelope from app.services.auth_service import AuthPrincipal, AuthService +from app.services.authentik_oidc_service import AuthentikOidcService from app.services.demo_workflow_service import DemoWorkflowService router = APIRouter(prefix="/auth", tags=["auth"]) COOKIE_NAME = "geointel_session" +OIDC_FLOW_COOKIE_NAME = "geointel_oidc_flow" +logger = logging.getLogger("geointel.auth") -def _session_from_principal(principal: AuthPrincipal, *, guest_access_enabled: bool) -> AuthSession: +def _session_from_principal( + principal: AuthPrincipal, + *, + guest_access_enabled: bool, + authentik_enabled: bool, +) -> AuthSession: return AuthSession( authentication_required=True, authenticated=True, @@ -25,6 +35,7 @@ def _session_from_principal(principal: AuthPrincipal, *, guest_access_enabled: b expires_at=datetime.fromtimestamp(principal.expires_at, tz=UTC), role=principal.role, guest_access_enabled=guest_access_enabled, + authentik_enabled=authentik_enabled, guest_project_id=principal.project_id, ) @@ -32,11 +43,13 @@ def _session_from_principal(principal: AuthPrincipal, *, guest_access_enabled: b def _session_payload(request: Request) -> AuthSession: settings = get_settings() guest_access_enabled = settings.auth_enabled and settings.guest_access_enabled + authentik_enabled = AuthentikOidcService(settings).enabled if not settings.auth_enabled: return AuthSession( authentication_required=False, authenticated=True, guest_access_enabled=False, + authentik_enabled=False, ) principal = AuthService.verify_session_token(request.cookies.get(COOKIE_NAME), settings) if principal is None: @@ -44,10 +57,12 @@ def _session_payload(request: Request) -> AuthSession: authentication_required=True, authenticated=False, guest_access_enabled=guest_access_enabled, + authentik_enabled=authentik_enabled, ) return _session_from_principal( principal, guest_access_enabled=guest_access_enabled, + authentik_enabled=authentik_enabled, ) @@ -120,10 +135,83 @@ def login(payload: AuthLoginRequest, request: Request, response: Response) -> Au data=_session_from_principal( principal, guest_access_enabled=settings.guest_access_enabled, + authentik_enabled=AuthentikOidcService(settings).enabled, ) ) +@router.get("/authentik/start") +def authentik_start(request: Request) -> RedirectResponse: + settings = get_settings() + service = AuthentikOidcService(settings) + try: + location, flow = service.start() + except Exception as exc: + logger.warning("Authentik authorization start failed: %s", type(exc).__name__) + raise AppError( + code="AUTHENTIK_UNAVAILABLE", + message="Authentik is momenteel niet beschikbaar.", + status_code=status.HTTP_503_SERVICE_UNAVAILABLE, + ) from exc + response = RedirectResponse(location, status_code=status.HTTP_302_FOUND) + response.set_cookie( + OIDC_FLOW_COOKIE_NAME, + flow, + max_age=600, + httponly=True, + secure=True, + samesite="lax", + path=f"{settings.api_prefix}/auth/authentik", + ) + return response + + +@router.get("/authentik/callback") +def authentik_callback( + request: Request, + code: str = "", + state: str = "", +) -> RedirectResponse: + settings = get_settings() + service = AuthentikOidcService(settings) + base_url = settings.public_base_url.rstrip("/") + try: + service.finish( + code=code, + state=state, + flow_cookie=request.cookies.get(OIDC_FLOW_COOKIE_NAME, ""), + ) + token = AuthService.create_session_token( + settings.auth_username or "operator", + settings, + ) + except Exception as exc: + logger.warning("Authentik callback rejected: %s", type(exc).__name__) + response = RedirectResponse( + f"{base_url}/?authentik=error", + status_code=status.HTTP_302_FOUND, + ) + else: + response = RedirectResponse( + f"{base_url}/", + status_code=status.HTTP_302_FOUND, + ) + _set_session_cookie( + request=request, + response=response, + token=token, + max_age=settings.auth_session_ttl_seconds, + ) + response.delete_cookie( + OIDC_FLOW_COOKIE_NAME, + path=f"{settings.api_prefix}/auth/authentik", + secure=True, + httponly=True, + samesite="lax", + ) + return response + + @router.post("/guest", response_model=AuthSessionEnvelope) def guest_login( request: Request, @@ -163,6 +251,7 @@ def guest_login( data=_session_from_principal( principal, guest_access_enabled=True, + authentik_enabled=AuthentikOidcService(settings).enabled, ) ) @@ -176,5 +265,6 @@ def logout(response: Response) -> AuthSessionEnvelope: authentication_required=settings.auth_enabled, authenticated=not settings.auth_enabled, guest_access_enabled=settings.auth_enabled and settings.guest_access_enabled, + authentik_enabled=AuthentikOidcService(settings).enabled, ) ) diff --git a/backend/app/core/config.py b/backend/app/core/config.py index e530c78d..ebcbddee 100644 --- a/backend/app/core/config.py +++ b/backend/app/core/config.py @@ -1,4 +1,6 @@ -from pydantic import Field, field_validator, model_validator +from urllib.parse import urlsplit + +from pydantic import AliasChoices, Field, field_validator, model_validator from pydantic_settings import BaseSettings, SettingsConfigDict @@ -22,6 +24,14 @@ class Settings(BaseSettings): auth_username: str | None = Field(default=None, validation_alias="GEOINTEL_AUTH_USERNAME") auth_password_hash: str | None = Field(default=None, validation_alias="GEOINTEL_AUTH_PASSWORD_HASH") auth_session_secret: str | None = Field(default=None, validation_alias="GEOINTEL_AUTH_SESSION_SECRET") + authentik_issuer: str | None = Field(default=None, validation_alias="GEOINTEL_AUTHENTIK_ISSUER") + authentik_client_id: str | None = Field(default=None, validation_alias="GEOINTEL_AUTHENTIK_CLIENT_ID") + authentik_client_secret: str | None = Field(default=None, validation_alias="GEOINTEL_AUTHENTIK_CLIENT_SECRET") + authentik_allowed_email: str | None = Field(default=None, validation_alias="GEOINTEL_AUTHENTIK_ALLOWED_EMAIL") + public_base_url: str = Field( + default="http://localhost:1202", + validation_alias="GEOINTEL_PUBLIC_BASE_URL", + ) auth_session_ttl_seconds: int = Field( default=43_200, ge=900, @@ -54,7 +64,18 @@ class Settings(BaseSettings): allow_external_artifact_paths: bool = Field( default=False, validation_alias="GEOINTEL_ALLOW_EXTERNAL_ARTIFACT_PATHS" ) - max_upload_mb: int = Field(default=500, validation_alias="MAX_UPLOAD_MB") + max_upload_mb: int = Field( + default=500, + ge=1, + le=2_048, + validation_alias=AliasChoices("GEOINTEL_MAX_UPLOAD_MB", "MAX_UPLOAD_MB"), + ) + max_in_memory_vector_mb: int = Field( + default=64, + ge=1, + le=256, + validation_alias="GEOINTEL_MAX_IN_MEMORY_VECTOR_MB", + ) orthophoto_enabled: bool = Field(default=True, validation_alias="ORTHOPHOTO_ENABLED") orthophoto_wms_url: str = Field( default="https://geo.api.vlaanderen.be/OMWRGBMRVL/wms", @@ -470,6 +491,47 @@ class Settings(BaseSettings): self.guest_display_name = self.guest_display_name.strip() if not self.guest_display_name: raise ValueError("GEOINTEL_GUEST_DISPLAY_NAME must not be blank") + for field_name in ( + "authentik_issuer", + "authentik_client_id", + "authentik_client_secret", + "authentik_allowed_email", + ): + value = getattr(self, field_name) + setattr(self, field_name, value.strip() if value else None) + self.public_base_url = self.public_base_url.strip().rstrip("/") + authentik_values = ( + self.authentik_issuer, + self.authentik_client_id, + self.authentik_client_secret, + self.authentik_allowed_email, + ) + if any(authentik_values) and not all(authentik_values): + raise ValueError("All GEOINTEL_AUTHENTIK_* values must be configured together") + if all(authentik_values): + if not self.auth_enabled: + raise ValueError("GEOINTEL_AUTH_ENABLED must be true when Authentik is configured") + for label, value in ( + ("GEOINTEL_AUTHENTIK_ISSUER", self.authentik_issuer), + ("GEOINTEL_PUBLIC_BASE_URL", self.public_base_url), + ): + parsed = urlsplit(str(value)) + if ( + parsed.scheme != "https" + or not parsed.hostname + or parsed.username + or parsed.password + or parsed.query + or parsed.fragment + ): + raise ValueError(f"{label} must be an absolute HTTPS URL without credentials, query or fragment") + public_url = urlsplit(self.public_base_url) + if public_url.path not in ("", "/"): + raise ValueError("GEOINTEL_PUBLIC_BASE_URL must not contain a path") + if "@" not in str(self.authentik_allowed_email) or any( + character.isspace() for character in str(self.authentik_allowed_email) + ): + raise ValueError("GEOINTEL_AUTHENTIK_ALLOWED_EMAIL must be one valid e-mail address") if not self.auth_enabled: return self if not (self.auth_username or "").strip(): diff --git a/backend/app/main.py b/backend/app/main.py index f9fcd5f8..c45d1ce8 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -26,7 +26,7 @@ from app.services.aoi_operation_worker import AoiOperationWorker logger = logging.getLogger("geointel") SAFE_REQUEST_ID = re.compile(r"^[A-Za-z0-9._:-]{1,128}$") -UNSAFE_HOST = re.compile(r"[/\\@\s\x00-\x1f\x7f]") +UNSAFE_HOST = re.compile(r"[/\\@?#\s\x00-\x1f\x7f]") def _to_error_payload( @@ -157,6 +157,8 @@ def create_app() -> FastAPI: f"{settings.api_prefix}/auth/login", f"{settings.api_prefix}/auth/guest", f"{settings.api_prefix}/auth/logout", + f"{settings.api_prefix}/auth/authentik/start", + f"{settings.api_prefix}/auth/authentik/callback", } direct_loopback_request = ( request.client is not None @@ -261,6 +263,7 @@ def create_app() -> FastAPI: guest_safe_post_paths = { f"{settings.api_prefix}/demo/workflow", f"{settings.api_prefix}/external/coverage/resolve", + f"{settings.api_prefix}/analysis/change-detection", } guest_scoped_analysis_post_paths = { f"{settings.api_prefix}/detection/run", @@ -274,7 +277,10 @@ def create_app() -> FastAPI: f"{settings.api_prefix}/exports/map-result", } guest_safe_post_suffixes = ( + "/acquire", "/vector/select", + "/vector/select/derive", + "/raster/tile", "/raster/bathymetry/select", "/raster/terrain/select", "/raster/flood-hazard/select", @@ -313,7 +319,7 @@ def create_app() -> FastAPI: status_code=403, content=_to_error_payload( "GUEST_READ_ONLY", - "Gasttoegang is een tijdelijke, alleen-lezen demo. Meld u aan als operator om gegevens te wijzigen of taken te starten.", + "Gasttoegang laat alleen projectgebonden demo-analyses toe. Meld u aan als operator voor beheerwijzigingen.", request_id=request_id, ), ) diff --git a/backend/app/schemas/auth.py b/backend/app/schemas/auth.py index f21ffe92..bce26297 100644 --- a/backend/app/schemas/auth.py +++ b/backend/app/schemas/auth.py @@ -21,6 +21,7 @@ class AuthSession(BaseModel): expires_at: datetime | None = None role: Literal["operator", "guest"] | None = None guest_access_enabled: bool = False + authentik_enabled: bool = False guest_project_id: UUID | None = None diff --git a/backend/app/services/authentik_oidc_service.py b/backend/app/services/authentik_oidc_service.py new file mode 100644 index 00000000..8dfda26b --- /dev/null +++ b/backend/app/services/authentik_oidc_service.py @@ -0,0 +1,207 @@ +from __future__ import annotations + +import base64 +import hashlib +import json +import secrets +from typing import Any +from urllib.error import HTTPError +from urllib.parse import urlencode, urlsplit +from urllib.request import HTTPRedirectHandler, Request, build_opener + +import jwt +from itsdangerous import BadSignature, SignatureExpired, URLSafeTimedSerializer + +from app.core.config import Settings + + +MAX_OIDC_JSON_BYTES = 1_048_576 + + +class _RejectRedirects(HTTPRedirectHandler): + def redirect_request(self, req, fp, code, msg, headers, newurl): # noqa: ANN001, ANN201 + return None + + +class AuthentikOidcService: + def __init__(self, settings: Settings): + self.settings = settings + self.issuer = (settings.authentik_issuer or "").rstrip("/") + self.serializer = URLSafeTimedSerializer( + settings.auth_session_secret or "", + salt="geointel-authentik-v1", + ) + + @property + def enabled(self) -> bool: + return bool( + self.issuer + and self.settings.authentik_client_id + and self.settings.authentik_client_secret + and self.settings.authentik_allowed_email + ) + + @property + def redirect_uri(self) -> str: + return ( + f"{self.settings.public_base_url.rstrip('/')}" + f"{self.settings.api_prefix}/auth/authentik/callback" + ) + + @staticmethod + def _origin(url: str) -> tuple[str, str, int]: + parsed = urlsplit(url) + if parsed.scheme != "https" or not parsed.hostname: + raise ValueError("OIDC URLs must use absolute HTTPS URLs") + return parsed.scheme, parsed.hostname.casefold(), parsed.port or 443 + + def _validate_endpoint(self, url: str) -> str: + parsed = urlsplit(url) + if ( + self._origin(url) != self._origin(self.issuer) + or parsed.username + or parsed.password + or parsed.fragment + ): + raise ValueError("OIDC endpoint is outside the configured issuer origin") + return url + + def _fetch_json( + self, + url: str, + data: dict[str, str] | None = None, + ) -> dict[str, Any]: + self._validate_endpoint(url) + encoded = urlencode(data).encode("utf-8") if data is not None else None + headers = {"Accept": "application/json"} + if encoded is not None: + headers["Content-Type"] = "application/x-www-form-urlencoded" + request = Request(url, data=encoded, headers=headers) + try: + with build_opener(_RejectRedirects()).open(request, timeout=10) as response: + declared_length = response.headers.get("Content-Length") + if declared_length and int(declared_length) > MAX_OIDC_JSON_BYTES: + raise ValueError("OIDC response exceeds the configured size limit") + raw = response.read(MAX_OIDC_JSON_BYTES + 1) + except HTTPError as exc: + raise ValueError("OIDC endpoint returned an HTTP error or redirect") from exc + if len(raw) > MAX_OIDC_JSON_BYTES: + raise ValueError("OIDC response exceeds the configured size limit") + payload = json.loads(raw) + if not isinstance(payload, dict): + raise ValueError("OIDC endpoint did not return a JSON object") + return payload + + def _discovery(self) -> dict[str, Any]: + document = self._fetch_json( + f"{self.issuer}/.well-known/openid-configuration" + ) + if str(document.get("issuer", "")).rstrip("/") != self.issuer: + raise ValueError("OIDC issuer mismatch") + for key in ("authorization_endpoint", "token_endpoint", "jwks_uri"): + endpoint = document.get(key) + if not isinstance(endpoint, str): + raise ValueError(f"OIDC discovery is missing {key}") + self._validate_endpoint(endpoint) + return document + + def start(self) -> tuple[str, str]: + if not self.enabled: + raise ValueError("Authentik is not configured") + state = secrets.token_urlsafe(32) + nonce = secrets.token_urlsafe(32) + verifier = secrets.token_urlsafe(48) + flow = self.serializer.dumps( + {"state": state, "nonce": nonce, "verifier": verifier} + ) + challenge = ( + base64.urlsafe_b64encode(hashlib.sha256(verifier.encode()).digest()) + .rstrip(b"=") + .decode() + ) + discovery = self._discovery() + query = urlencode( + { + "client_id": self.settings.authentik_client_id, + "redirect_uri": self.redirect_uri, + "response_type": "code", + "scope": "openid email profile", + "state": state, + "nonce": nonce, + "code_challenge": challenge, + "code_challenge_method": "S256", + } + ) + return f"{discovery['authorization_endpoint']}?{query}", flow + + def finish(self, *, code: str, state: str, flow_cookie: str) -> dict[str, Any]: + if not self.enabled or not code: + raise ValueError("OIDC flow is incomplete") + try: + flow = self.serializer.loads(flow_cookie, max_age=600) + except (BadSignature, SignatureExpired) as exc: + raise ValueError("Invalid OIDC flow") from exc + if not isinstance(flow, dict): + raise ValueError("Invalid OIDC flow payload") + if not state or not secrets.compare_digest(state, str(flow.get("state", ""))): + raise ValueError("OIDC state mismatch") + verifier = str(flow.get("verifier", "")) + nonce = str(flow.get("nonce", "")) + if not verifier or not nonce: + raise ValueError("OIDC flow payload is incomplete") + + discovery = self._discovery() + token_response = self._fetch_json( + str(discovery["token_endpoint"]), + { + "grant_type": "authorization_code", + "code": code, + "redirect_uri": self.redirect_uri, + "client_id": self.settings.authentik_client_id or "", + "client_secret": self.settings.authentik_client_secret or "", + "code_verifier": verifier, + }, + ) + token = str(token_response.get("id_token", "")) + if not token: + raise ValueError("OIDC token response has no ID token") + header = jwt.get_unverified_header(token) + if header.get("alg") != "RS256" or not header.get("kid"): + raise ValueError("OIDC ID token uses an unsupported signing header") + jwks = self._fetch_json(str(discovery["jwks_uri"])) + matching_keys = [ + key + for key in jwks.get("keys", []) + if isinstance(key, dict) and key.get("kid") == header["kid"] + ] + if len(matching_keys) != 1: + raise ValueError("OIDC signing key is missing or ambiguous") + signing_key = jwt.PyJWK.from_dict(matching_keys[0]).key + claims = jwt.decode( + token, + signing_key, + algorithms=["RS256"], + audience=self.settings.authentik_client_id, + issuer=discovery["issuer"], + options={ + "require": [ + "exp", + "iat", + "iss", + "aud", + "sub", + "nonce", + "email", + "email_verified", + ] + }, + ) + if not secrets.compare_digest(str(claims.get("nonce", "")), nonce): + raise ValueError("OIDC nonce mismatch") + email = str(claims.get("email", "")).strip().casefold() + allowed = str(self.settings.authentik_allowed_email or "").strip().casefold() + if claims.get("email_verified") is not True or not secrets.compare_digest( + email, allowed + ): + raise ValueError("OIDC identity is not authorized") + return claims diff --git a/backend/tests/test_auth.py b/backend/tests/test_auth.py index 7e28b893..68aaa657 100644 --- a/backend/tests/test_auth.py +++ b/backend/tests/test_auth.py @@ -1,7 +1,8 @@ from __future__ import annotations from pathlib import Path -from uuid import UUID +from types import SimpleNamespace +from uuid import UUID, uuid4 from fastapi.testclient import TestClient @@ -10,7 +11,13 @@ from app.db.session import get_db from app.main import create_app from app.schemas.demo import DemoWorkflowResponse from app.services.auth_service import AuthService +from app.services.change_detection_service import ChangeDetectionService +from app.services.dataset_service import DatasetService +from app.services.detection_service import DetectionService from app.services.demo_workflow_service import DemoWorkflowService +from app.services.raster_operations_service import RasterOperationsService +from app.services.segmentation_service import SegmentationService +from app.services.job_service import JobService def auth_client(monkeypatch, *, guest_access: bool = False) -> TestClient: @@ -80,6 +87,7 @@ def test_auth_session_and_health_are_public_but_api_is_protected(monkeypatch) -> "expires_at": None, "role": None, "guest_access_enabled": False, + "authentik_enabled": False, "guest_project_id": None, } assert protected.status_code == 401 @@ -113,6 +121,7 @@ def test_login_uses_http_only_session_cookie_and_logout_revokes_browser_access(m "expires_at": login.json()["data"]["expires_at"], "role": "operator", "guest_access_enabled": True, + "authentik_enabled": False, "guest_project_id": None, } cookie = login.headers["set-cookie"].lower() @@ -166,6 +175,18 @@ def test_guest_login_exposes_models_but_rejects_management_and_cross_project_req "themes": [], }, ) + bounded_acquisition = client.post( + f"/api/v1/projects/{project_id}/datasets/orthophoto/acquire", + json={}, + ) + cross_project_acquisition = client.post( + "/api/v1/projects/00000000-0000-0000-0000-000000000999/datasets/orthophoto/acquire", + json={}, + ) + bounded_derived_selection = client.post( + f"/api/v1/projects/{project_id}/datasets/{demo.candidate_dataset_id}/vector/select/derive", + json={}, + ) assert guest_login.status_code == 200 assert guest_login.json()["data"]["role"] == "guest" @@ -186,6 +207,218 @@ def test_guest_login_exposes_models_but_rejects_management_and_cross_project_req assert cross_project_runs.json()["error"] == "GUEST_PROJECT_SCOPE_REQUIRED" assert cross_project_coverage.status_code == 403 assert cross_project_coverage.json()["error"] == "GUEST_PROJECT_SCOPE_REQUIRED" + assert bounded_acquisition.status_code == 422 + assert bounded_acquisition.json()["error"] != "GUEST_READ_ONLY" + assert cross_project_acquisition.status_code == 403 + assert cross_project_acquisition.json()["error"] == "GUEST_PROJECT_SCOPE_REQUIRED" + assert bounded_derived_selection.status_code == 422 + assert bounded_derived_selection.json()["error"] != "GUEST_READ_ONLY" + + +def test_guest_change_detection_binds_both_datasets_to_signed_demo_project(monkeypatch) -> None: + project_id = UUID("00000000-0000-0000-0000-000000000123") + other_project_id = UUID("00000000-0000-0000-0000-000000000999") + source_dataset_id = UUID("00000000-0000-0000-0000-000000000125") + target_dataset_id = UUID("00000000-0000-0000-0000-000000000126") + cross_project_dataset_id = UUID("00000000-0000-0000-0000-000000000998") + demo = DemoWorkflowResponse( + project_id=project_id, + area_id=UUID("00000000-0000-0000-0000-000000000124"), + reference_dataset_id=source_dataset_id, + candidate_dataset_id=target_dataset_id, + raster_dataset_id=UUID("00000000-0000-0000-0000-000000000127"), + quality_check_id=UUID("00000000-0000-0000-0000-000000000128"), + metric_count=6, + status="ok", + message="Demo ready", + created=False, + ) + monkeypatch.setattr(DemoWorkflowService, "seed", staticmethod(lambda _db: demo)) + + class FakeDb: + def get(self, _model, dataset_id): + bound_project_id = other_project_id if dataset_id == cross_project_dataset_id else project_id + return SimpleNamespace(id=dataset_id, project_id=bound_project_id, dataset_type="vector") + + validated_datasets: list[tuple[UUID, UUID, str]] = [] + + def validate_dataset(_db, dataset_id, requested_project_id, label): + validated_datasets.append((dataset_id, requested_project_id, label)) + return SimpleNamespace(id=dataset_id, project_id=requested_project_id, dataset_type="vector") + + monkeypatch.setattr( + ChangeDetectionService, + "_get_project_vector_dataset", + staticmethod(validate_dataset), + ) + monkeypatch.setattr( + JobService, + "run_sync_job", + staticmethod( + lambda **kwargs: SimpleNamespace( + id=uuid4(), + job_type=kwargs["job_type"], + status="success", + project_id=kwargs["project_id"], + dataset_id=source_dataset_id, + input_dataset_id=source_dataset_id, + output_dataset_id=None, + parameters_json=kwargs["parameters"], + result_json={}, + error_message=None, + created_at=None, + started_at=None, + finished_at=None, + ) + ), + ) + client = auth_client(monkeypatch, guest_access=True) + + def fake_db(): + yield FakeDb() + + client.app.dependency_overrides[get_db] = fake_db + assert client.post("/api/v1/auth/guest").status_code == 200 + + accepted = client.post( + "/api/v1/analysis/change-detection", + json={ + "source_dataset_id": str(source_dataset_id), + "target_dataset_id": str(target_dataset_id), + }, + ) + rejected = client.post( + "/api/v1/analysis/change-detection", + json={ + "source_dataset_id": str(cross_project_dataset_id), + "target_dataset_id": str(target_dataset_id), + }, + ) + + assert accepted.status_code == 200 + assert accepted.json()["data"]["project_id"] == str(project_id) + assert validated_datasets == [ + (source_dataset_id, project_id, "Source"), + (target_dataset_id, project_id, "Target"), + ] + assert rejected.status_code == 403 + assert rejected.json()["error"] == "GUEST_PROJECT_SCOPE_REQUIRED" + + +def test_guest_can_prepare_tiles_and_queue_project_scoped_detection(monkeypatch) -> None: + project_id = UUID("00000000-0000-0000-0000-000000000123") + raster_dataset_id = UUID("00000000-0000-0000-0000-000000000127") + manifest_path = "/app/storage/tiles/demo/manifest.json" + demo = DemoWorkflowResponse( + project_id=project_id, + area_id=UUID("00000000-0000-0000-0000-000000000124"), + reference_dataset_id=UUID("00000000-0000-0000-0000-000000000125"), + candidate_dataset_id=UUID("00000000-0000-0000-0000-000000000126"), + raster_dataset_id=raster_dataset_id, + quality_check_id=UUID("00000000-0000-0000-0000-000000000128"), + metric_count=6, + status="ok", + message="Demo ready", + created=False, + ) + monkeypatch.setattr(DemoWorkflowService, "seed", staticmethod(lambda _db: demo)) + monkeypatch.setattr( + DatasetService, + "get_dataset", + staticmethod(lambda _db, _dataset_id: SimpleNamespace(project_id=project_id)), + ) + + def job(*, job_type: str, result_json: dict | None = None): + return SimpleNamespace( + id=uuid4(), + job_type=job_type, + status="success" if result_json else "queued", + project_id=project_id, + dataset_id=raster_dataset_id, + input_dataset_id=raster_dataset_id, + output_dataset_id=None, + parameters_json={}, + result_json=result_json, + error_message=None, + created_at=None, + started_at=None, + finished_at=None, + ) + + tile_parameters: dict = {} + + def tile(_db, _dataset_id, **kwargs): + tile_parameters.update(kwargs) + return {"manifest_path": manifest_path} + + monkeypatch.setattr(RasterOperationsService, "tile", staticmethod(tile)) + monkeypatch.setattr( + "app.api.routes.datasets._run_job_sync", + lambda **kwargs: job(job_type="raster.tile", result_json=kwargs["operation"]()), + ) + queued_parameters: dict = {} + + def enqueue_detection(**kwargs): + queued_parameters.update(kwargs) + return job(job_type="detection.run") + + monkeypatch.setattr(DetectionService, "enqueue_detection", staticmethod(enqueue_detection)) + queued_segmentation_parameters: dict = {} + + def enqueue_segmentation(**kwargs): + queued_segmentation_parameters.update(kwargs) + return job(job_type="segmentation.run") + + monkeypatch.setattr(SegmentationService, "enqueue_segmentation", staticmethod(enqueue_segmentation)) + client = auth_client(monkeypatch, guest_access=True) + + def fake_db(): + yield object() + + client.app.dependency_overrides[get_db] = fake_db + assert client.post("/api/v1/auth/guest").status_code == 200 + + tile_response = client.post( + f"/api/v1/projects/{project_id}/datasets/{raster_dataset_id}/raster/tile", + json={"tile_size": 512, "overlap": 64}, + ) + detection_response = client.post( + f"/api/v1/detection/run-async?project_id={project_id}", + json={ + "project_id": str(project_id), + "dataset_id": str(raster_dataset_id), + "model_id": "yolo-configured", + "model_asset_id": "active-model", + "confidence_threshold": 0.15, + "tile_manifest_path": manifest_path, + "parameters_json": {}, + }, + ) + segmentation_response = client.post( + f"/api/v1/segmentation/run-async?project_id={project_id}", + json={ + "project_id": str(project_id), + "dataset_id": str(raster_dataset_id), + "model_id": "sam-configured", + "confidence_threshold": 0.5, + "tile_manifest_path": manifest_path, + "parameters_json": {}, + }, + ) + + assert tile_response.status_code == 201 + assert tile_response.json()["data"]["result_json"]["manifest_path"] == manifest_path + assert detection_response.status_code == 200 + assert detection_response.json()["data"]["status"] == "queued" + assert segmentation_response.status_code == 200 + assert segmentation_response.json()["data"]["status"] == "queued" + assert queued_parameters["project_id"] == project_id + assert queued_parameters["dataset_id"] == raster_dataset_id + assert queued_parameters["tile_manifest_path"] == manifest_path + assert queued_segmentation_parameters["project_id"] == project_id + assert queued_segmentation_parameters["dataset_id"] == raster_dataset_id + assert queued_segmentation_parameters["tile_manifest_path"] == manifest_path + assert tile_parameters["max_tiles"] == get_settings().yolo_max_tiles def test_guest_session_tokens_fail_closed_without_a_project_scope(monkeypatch) -> None: @@ -223,7 +456,9 @@ def test_unraid_runtime_carries_only_hashed_operator_credentials() -> None: browser_smoke = (root / "scripts/verify_browser_runtime.sh").read_text(encoding="utf-8") assert '-e GEOINTEL_AUTH_PASSWORD_HASH="$GEOINTEL_AUTH_PASSWORD_HASH"' in runner + assert '-e GEOINTEL_AUTHENTIK_CLIENT_SECRET="$GEOINTEL_AUTHENTIK_CLIENT_SECRET"' in runner assert "GEOINTEL_AUTH_PASSWORD_HASH=" in example + assert "GEOINTEL_AUTHENTIK_CLIENT_SECRET=" in example assert "GEOINTEL_AUTH_PASSWORD=" not in runner assert "GEOINTEL_GUEST_ACCESS_ENABLED=true" in example assert 'GEOINTEL_GUEST_ACCESS_ENABLED="${GEOINTEL_GUEST_ACCESS_ENABLED:-true}"' in runner diff --git a/backend/tests/test_authentik_oidc_service.py b/backend/tests/test_authentik_oidc_service.py new file mode 100644 index 00000000..5b870425 --- /dev/null +++ b/backend/tests/test_authentik_oidc_service.py @@ -0,0 +1,183 @@ +from __future__ import annotations + +import time +from urllib.parse import parse_qs, urlsplit + +import jwt +import pytest +from cryptography.hazmat.primitives.asymmetric import rsa +from pydantic import ValidationError + +from app.core.config import Settings +from app.services.authentik_oidc_service import ( + MAX_OIDC_JSON_BYTES, + AuthentikOidcService, +) + + +ISSUER = "https://auth.example.test/application/o/geointel" + + +def configured_settings(**overrides: object) -> Settings: + values: dict[str, object] = { + "auth_enabled": True, + "auth_username": "ITWorx", + "auth_password_hash": "pbkdf2_sha256$1$salt$digest", + "auth_session_secret": "s" * 48, + "authentik_issuer": ISSUER, + "authentik_client_id": "geointel-client", + "authentik_client_secret": "client-secret", + "authentik_allowed_email": "operator@example.test", + "public_base_url": "https://geointel.example.test", + } + values.update(overrides) + return Settings(_env_file=None, **values) + + +def discovery_document() -> dict[str, str]: + return { + "issuer": ISSUER, + "authorization_endpoint": f"{ISSUER}/authorize", + "token_endpoint": f"{ISSUER}/token", + "jwks_uri": f"{ISSUER}/jwks", + } + + +def test_authentik_configuration_is_all_or_nothing_and_https_only() -> None: + with pytest.raises(ValidationError, match="configured together"): + configured_settings(authentik_client_secret=None) + with pytest.raises(ValidationError, match="absolute HTTPS URL"): + configured_settings(authentik_issuer="http://auth.example.test/issuer") + with pytest.raises(ValidationError, match="must not contain a path"): + configured_settings(public_base_url="https://geointel.example.test/app") + + +def test_start_uses_same_origin_discovery_and_pkce(monkeypatch: pytest.MonkeyPatch) -> None: + service = AuthentikOidcService(configured_settings()) + monkeypatch.setattr(service, "_fetch_json", lambda *_args, **_kwargs: discovery_document()) + + location, flow_cookie = service.start() + + parsed = urlsplit(location) + query = parse_qs(parsed.query) + flow = service.serializer.loads(flow_cookie, max_age=600) + assert f"{parsed.scheme}://{parsed.netloc}{parsed.path}" == f"{ISSUER}/authorize" + assert query["redirect_uri"] == [ + "https://geointel.example.test/api/v1/auth/authentik/callback" + ] + assert query["code_challenge_method"] == ["S256"] + assert query["state"] == [flow["state"]] + assert query["nonce"] == [flow["nonce"]] + assert query["code_challenge"][0] + + +def test_discovery_rejects_cross_origin_endpoints(monkeypatch: pytest.MonkeyPatch) -> None: + service = AuthentikOidcService(configured_settings()) + document = discovery_document() + document["jwks_uri"] = "https://attacker.example.test/jwks" + monkeypatch.setattr(service, "_fetch_json", lambda *_args, **_kwargs: document) + + with pytest.raises(ValueError, match="outside the configured issuer origin"): + service._discovery() + + +def test_finish_verifies_signature_nonce_and_exact_allowed_email( + monkeypatch: pytest.MonkeyPatch, +) -> None: + service = AuthentikOidcService(configured_settings()) + state, nonce, verifier = "state-value", "nonce-value", "verifier-value" + flow_cookie = service.serializer.dumps( + {"state": state, "nonce": nonce, "verifier": verifier} + ) + private_key = rsa.generate_private_key(public_exponent=65537, key_size=2048) + public_jwk = jwt.algorithms.RSAAlgorithm.to_jwk( + private_key.public_key(), as_dict=True + ) + public_jwk["kid"] = "operator-key" + now = int(time.time()) + token = jwt.encode( + { + "iss": ISSUER, + "aud": "geointel-client", + "sub": "authentik-user-id", + "iat": now, + "exp": now + 300, + "nonce": nonce, + "email": "Operator@Example.Test", + "email_verified": True, + }, + private_key, + algorithm="RS256", + headers={"kid": "operator-key"}, + ) + token_holder = {"value": token} + + def fetch(url: str, data: dict[str, str] | None = None) -> dict: + if url.endswith("openid-configuration"): + return discovery_document() + if url.endswith("/token"): + assert data is not None + assert data["code_verifier"] == verifier + return {"id_token": token_holder["value"]} + if url.endswith("/jwks"): + return {"keys": [public_jwk]} + raise AssertionError(url) + + monkeypatch.setattr(service, "_fetch_json", fetch) + + claims = service.finish(code="authorization-code", state=state, flow_cookie=flow_cookie) + + assert claims["sub"] == "authentik-user-id" + token_holder["value"] = jwt.encode( + { + "iss": ISSUER, + "aud": "geointel-client", + "sub": "different-user", + "iat": now, + "exp": now + 300, + "nonce": nonce, + "email": "other@example.test", + "email_verified": True, + }, + private_key, + algorithm="RS256", + headers={"kid": "operator-key"}, + ) + with pytest.raises(ValueError, match="not authorized"): + service.finish(code="authorization-code", state=state, flow_cookie=flow_cookie) + with pytest.raises(ValueError, match="state mismatch"): + service.finish( + code="authorization-code", + state="different-state", + flow_cookie=flow_cookie, + ) + + +def test_fetch_json_rejects_declared_oversize_response( + monkeypatch: pytest.MonkeyPatch, +) -> None: + service = AuthentikOidcService(configured_settings()) + + class OversizeResponse: + headers = {"Content-Length": str(MAX_OIDC_JSON_BYTES + 1)} + + def __enter__(self): + return self + + def __exit__(self, *_args: object) -> None: + return None + + def read(self, _size: int) -> bytes: + raise AssertionError("oversized responses must not be read") + + class Opener: + def open(self, *_args: object, **_kwargs: object) -> OversizeResponse: + return OversizeResponse() + + monkeypatch.setattr( + "app.services.authentik_oidc_service.build_opener", + lambda *_args: Opener(), + ) + + with pytest.raises(ValueError, match="size limit"): + service._fetch_json(f"{ISSUER}/oversized") diff --git a/backend/tests/test_request_target_security.py b/backend/tests/test_request_target_security.py index 7a343221..2e8c46be 100644 --- a/backend/tests/test_request_target_security.py +++ b/backend/tests/test_request_target_security.py @@ -1,3 +1,4 @@ +import pytest from fastapi.testclient import TestClient from app.main import app @@ -6,8 +7,16 @@ from app.main import app client = TestClient(app) -def test_invalid_host_request_target_is_rejected_canonically() -> None: - response = client.get("/health/live", headers={"host": "trusted.example/@admin"}) +@pytest.mark.parametrize( + "host", + [ + "trusted.example/@admin", + "trusted.example?shadow=admin", + "trusted.example#shadow", + ], +) +def test_invalid_host_request_target_is_rejected_canonically(host: str) -> None: + response = client.get("/health/live", headers={"host": host}) assert response.status_code == 400 assert response.headers["x-request-id"] @@ -15,6 +24,16 @@ def test_invalid_host_request_target_is_rejected_canonically() -> None: assert response.json()["request_id"] == response.headers["x-request-id"] +@pytest.mark.parametrize( + "host", + ["localhost:1202", "127.0.0.1:8000", "[::1]:8000", "testserver"], +) +def test_normal_host_forms_remain_available(host: str) -> None: + response = client.get("/health/live", headers={"host": host}) + + assert response.status_code == 200 + + def test_urlencoded_form_body_is_rejected_before_starlette_form_parsing() -> None: response = client.post( "/api/v1/datasets/upload", diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 1d93f3b5..728354c6 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -26,6 +26,7 @@ function App(): JSX.Element { ) diff --git a/frontend/src/components/auth/LandingPage.test.tsx b/frontend/src/components/auth/LandingPage.test.tsx index 55fbb8d3..dfe24f62 100644 --- a/frontend/src/components/auth/LandingPage.test.tsx +++ b/frontend/src/components/auth/LandingPage.test.tsx @@ -16,6 +16,7 @@ const operatorSession = { expires_at: '2026-07-27T20:00:00Z', role: 'operator' as const, guest_access_enabled: true, + authentik_enabled: false, guest_project_id: null, } @@ -26,6 +27,7 @@ const guestSession = { expires_at: '2026-07-27T20:00:00Z', role: 'guest' as const, guest_access_enabled: true, + authentik_enabled: false, guest_project_id: '00000000-0000-0000-0000-000000000123', } @@ -71,6 +73,15 @@ describe('LandingPage', () => { expect(screen.getByRole('button', { name: 'Open de workbench' })).toBeTruthy() }) + it('offers Authentik without removing the local operator recovery login', () => { + render() + + const authentik = screen.getByRole('link', { name: 'Aanmelden met Authentik' }) + expect(authentik.getAttribute('href')).toBe('/api/v1/auth/authentik/start') + expect(screen.getByLabelText('Gebruikersnaam')).toBeTruthy() + expect(screen.getByLabelText('Wachtwoord')).toBeTruthy() + }) + it('surfaces a useful authentication error without entering the workbench', async () => { vi.mocked(login).mockRejectedValue(new Error('Gebruikersnaam of wachtwoord is onjuist.')) render() diff --git a/frontend/src/components/auth/LandingPage.tsx b/frontend/src/components/auth/LandingPage.tsx index 1f16014f..3ff1e68d 100644 --- a/frontend/src/components/auth/LandingPage.tsx +++ b/frontend/src/components/auth/LandingPage.tsx @@ -28,6 +28,7 @@ interface LandingPageProps { onAuthenticated: (session: AuthSession) => void serviceError?: string | null guestAccessEnabled?: boolean + authentikEnabled?: boolean } const capabilityItems = [ @@ -62,6 +63,7 @@ export function LandingPage({ onAuthenticated, serviceError = null, guestAccessEnabled = false, + authentikEnabled = false, }: LandingPageProps): JSX.Element { const [username, setUsername] = useState('') const [password, setPassword] = useState('') @@ -79,6 +81,16 @@ export function LandingPage({ return () => document.body.classList.remove('landing-body') }, []) + useEffect(() => { + const query = new URLSearchParams(window.location.search) + if (query.get('authentik') !== 'error') return + setAttempted(true) + setAuthError('Aanmelden via Authentik is niet gelukt. Probeer opnieuw of gebruik de lokale operatorlogin.') + query.delete('authentik') + const suffix = query.toString() + window.history.replaceState(null, '', `${window.location.pathname}${suffix ? `?${suffix}` : ''}${window.location.hash}`) + }, []) + const scrollAccessPanelIntoView = () => { if (typeof accessPanelRef.current?.scrollIntoView !== 'function') return const reducedMotion = window.matchMedia?.('(prefers-reduced-motion: reduce)').matches ?? false @@ -247,6 +259,17 @@ export function LandingPage({
+ {authentikEnabled ? ( + + + ) : null} + {authentikEnabled ? ( +
+ of met lokale operatorgegevens +
+ ) : null} ({ ...signedOutSession, guest_access_enabled: current?.guest_access_enabled ?? false, + authentik_enabled: current?.authentik_enabled ?? false, })) setSessionError('Uw sessie is verlopen. Meld u opnieuw aan.') } diff --git a/frontend/src/lib/accessCapabilities.test.ts b/frontend/src/lib/accessCapabilities.test.ts new file mode 100644 index 00000000..804f4068 --- /dev/null +++ b/frontend/src/lib/accessCapabilities.test.ts @@ -0,0 +1,34 @@ +import { describe, expect, it } from 'vitest' + +import { getWorkbenchAccessCapabilities } from './accessCapabilities' + +describe('workbench access capabilities', () => { + it('keeps the complete analysis journey available to the demo', () => { + const guest = getWorkbenchAccessCapabilities('guest') + + expect(guest).toMatchObject({ + analyzePersistedData: true, + selectModels: true, + runQualityChecks: true, + exportResults: true, + acquireSources: true, + writeDerivedDatasets: true, + runChangeDetection: true, + }) + }) + + it('does not advertise operator-only mutations to a demo session', () => { + const guest = getWorkbenchAccessCapabilities('guest') + + expect(guest).toMatchObject({ + manageWorkspace: false, + manageModels: false, + reviewEvidence: false, + }) + }) + + it('keeps local open mode and an operator fully capable', () => { + expect(getWorkbenchAccessCapabilities('open')).toEqual(getWorkbenchAccessCapabilities('operator')) + expect(Object.values(getWorkbenchAccessCapabilities('operator')).every(Boolean)).toBe(true) + }) +}) diff --git a/frontend/src/lib/accessCapabilities.ts b/frontend/src/lib/accessCapabilities.ts new file mode 100644 index 00000000..baf9bef7 --- /dev/null +++ b/frontend/src/lib/accessCapabilities.ts @@ -0,0 +1,45 @@ +export type WorkbenchAccessMode = 'open' | 'operator' | 'guest' + +export interface WorkbenchAccessCapabilities { + analyzePersistedData: boolean + selectModels: boolean + runQualityChecks: boolean + exportResults: boolean + acquireSources: boolean + manageWorkspace: boolean + manageModels: boolean + writeDerivedDatasets: boolean + reviewEvidence: boolean + runChangeDetection: boolean +} + +const OPERATOR_CAPABILITIES: WorkbenchAccessCapabilities = { + analyzePersistedData: true, + selectModels: true, + runQualityChecks: true, + exportResults: true, + acquireSources: true, + manageWorkspace: true, + manageModels: true, + writeDerivedDatasets: true, + reviewEvidence: true, + runChangeDetection: true, +} + +const GUEST_CAPABILITIES: WorkbenchAccessCapabilities = { + analyzePersistedData: true, + selectModels: true, + runQualityChecks: true, + exportResults: true, + acquireSources: true, + manageWorkspace: false, + manageModels: false, + writeDerivedDatasets: true, + reviewEvidence: false, + runChangeDetection: true, +} + +/** Mirrors the backend's explicit guest route boundary without weakening it. */ +export function getWorkbenchAccessCapabilities(mode: WorkbenchAccessMode): WorkbenchAccessCapabilities { + return mode === 'guest' ? GUEST_CAPABILITIES : OPERATOR_CAPABILITIES +} diff --git a/frontend/src/services/api/auth.ts b/frontend/src/services/api/auth.ts index 07349413..d259f843 100644 --- a/frontend/src/services/api/auth.ts +++ b/frontend/src/services/api/auth.ts @@ -7,6 +7,7 @@ export interface AuthSession { expires_at: string | null role: 'operator' | 'guest' | null guest_access_enabled: boolean + authentik_enabled: boolean guest_project_id: string | null }