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