208 lines
7.9 KiB
Python
208 lines
7.9 KiB
Python
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
|