preserve Tower Authentik operator login WIP
GeoIntel release gates / Compile, test, contracts and builds (push) Failing after 1m51s
GeoIntel release gates / Python and npm vulnerability policy (push) Failing after 40s
GeoIntel release gates / GIS image, SBOM and container scan (push) Failing after 2m18s

This commit is contained in:
Jens
2026-08-30 03:05:29 +02:00
parent 3627a05bfe
commit 4fdc3aa11b
18 changed files with 744 additions and 201 deletions
+36
View File
@@ -3,6 +3,7 @@ from __future__ import annotations
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,6 +11,7 @@ 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
@@ -18,6 +20,7 @@ COOKIE_NAME = "geointel_session"
def _session_from_principal(principal: AuthPrincipal, *, guest_access_enabled: bool) -> AuthSession:
settings = get_settings()
return AuthSession(
authentication_required=True,
authenticated=True,
@@ -25,6 +28,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=AuthentikOidcService(settings).enabled,
guest_project_id=principal.project_id,
)
@@ -32,11 +36,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,6 +50,7 @@ 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,
@@ -124,6 +131,35 @@ def login(payload: AuthLoginRequest, request: Request, response: Response) -> Au
)
@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:
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)
forwarded_proto = request.headers.get("x-forwarded-proto", "").split(",", 1)[0].strip().lower()
response.set_cookie("geointel_oidc_flow", flow, max_age=600, httponly=True, secure=forwarded_proto == "https" or request.url.scheme == "https", 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)
try:
service.finish(code=code, state=state, flow_cookie=request.cookies.get("geointel_oidc_flow", ""))
token = AuthService.create_session_token(settings.auth_username or "operator", settings)
except Exception:
return RedirectResponse(f"{settings.public_base_url.rstrip('/')}?authentik=error", status_code=status.HTTP_302_FOUND)
response = RedirectResponse(settings.public_base_url.rstrip("/") + "/", 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("geointel_oidc_flow", path=f"{settings.api_prefix}/auth/authentik")
return response
@router.post("/guest", response_model=AuthSessionEnvelope)
def guest_login(
request: Request,
+20
View File
@@ -22,6 +22,11 @@ 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:8000", validation_alias="GEOINTEL_PUBLIC_BASE_URL")
auth_session_ttl_seconds: int = Field(
default=43_200,
ge=900,
@@ -470,6 +475,21 @@ 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")
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")
if not str(self.authentik_issuer).startswith("https://"):
raise ValueError("GEOINTEL_AUTHENTIK_ISSUER must use HTTPS")
if not self.public_base_url.startswith("https://"):
raise ValueError("GEOINTEL_PUBLIC_BASE_URL must use HTTPS for Authentik")
if not self.auth_enabled:
return self
if not (self.auth_username or "").strip():
+2
View File
@@ -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
+1
View File
@@ -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
@@ -0,0 +1,74 @@
from __future__ import annotations
import base64
import hashlib
import json
import secrets
from urllib.parse import urlencode, urlparse
from urllib.request import Request, urlopen
import jwt
from itsdangerous import BadSignature, SignatureExpired, URLSafeTimedSerializer
from app.core.config import Settings
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('/')}{self.settings.api_prefix}/auth/authentik/callback"
def _discovery(self) -> dict:
document = self._fetch_json(f"{self.issuer}/.well-known/openid-configuration")
if document.get("issuer", "").rstrip("/") != self.issuer:
raise ValueError("OIDC issuer mismatch")
issuer_origin = urlparse(self.issuer)
for key in ("authorization_endpoint", "token_endpoint", "jwks_uri"):
endpoint = urlparse(document[key])
if endpoint.scheme != "https" or (endpoint.hostname, endpoint.port) != (issuer_origin.hostname, issuer_origin.port):
raise ValueError("Untrusted OIDC endpoint")
return document
@staticmethod
def _fetch_json(url: str, data: dict[str, str] | None = None) -> dict:
encoded = urlencode(data).encode("utf-8") if data is not None else None
request = Request(url, data=encoded, headers={"Accept": "application/json"})
with urlopen(request, timeout=10) as response: # noqa: S310 - URL is validated against the configured HTTPS issuer.
return json.loads(response.read())
def start(self) -> tuple[str, str]:
if not self.enabled:
raise ValueError("Authentik is not configured")
state, nonce, verifier = secrets.token_urlsafe(32), secrets.token_urlsafe(32), 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:
try:
flow = self.serializer.loads(flow_cookie, max_age=600)
except (BadSignature, SignatureExpired) as exc:
raise ValueError("Invalid OIDC flow") from exc
if not state or not secrets.compare_digest(state, str(flow.get("state", ""))):
raise ValueError("OIDC state mismatch")
discovery = self._discovery()
token = self._fetch_json(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": flow["verifier"]}).get("id_token", "")
claims = jwt.decode(token, jwt.PyJWKClient(discovery["jwks_uri"]).get_signing_key_from_jwt(token).key, algorithms=["RS256"], audience=self.settings.authentik_client_id, issuer=discovery["issuer"], options={"require": ["exp", "iat", "iss", "aud", "sub", "nonce"]})
if claims.get("nonce") != flow["nonce"]:
raise ValueError("OIDC nonce mismatch")
email = str(claims.get("email", "")).strip().lower()
allowed = str(self.settings.authentik_allowed_email or "").strip().lower()
if claims.get("email_verified") is not True or not secrets.compare_digest(email, allowed):
raise ValueError("OIDC identity is not authorized")
return claims