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