75 lines
4.2 KiB
Python
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
|