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 {