Files
geointel/backend/app/services/authentik_oidc_service.py
T
Jens 4fdc3aa11b
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
preserve Tower Authentik operator login WIP
2026-08-30 03:05:29 +02:00

75 lines
4.2 KiB
Python

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