"""Recipe-local OIDC *session* login helper (authorization-code flow + session cookie). impress v5.4.0 removed Bearer-token (JWT) authentication on the API — the app now accepts only its own session cookie, established through the standard OIDC authorization-code browser flow (app login URL → keycloak login form → callback → Django session). This helper drives that flow with urllib + a CookieJar so the custom tests can exercise the API the way a real client does. Kept recipe-local (cf. tests/ghost/custom/_ghost.py precedent) rather than in runner/harness — promote it there if a third recipe needs it. Usage: sess = OidcSession(f"https://{live_app}") me = sess.login(kc["user"], kc["password"]) # asserts whoami 200; returns the user dict status, body = sess.post("/api/v1.0/documents/", {"title": "x"}) # CSRF handled """ from __future__ import annotations import contextlib import html import http.cookiejar import json import re import ssl import urllib.error import urllib.parse import urllib.request # Per-run *.ci.commoninternet.net domains serve the operator's wildcard cert via the Traefik file # provider; chain verification is done once in the install tier (generic.served_cert). _CTX = ssl.create_default_context() _CTX.check_hostname = False _CTX.verify_mode = ssl.CERT_NONE _LOGIN_PATHS = ("/api/v1.0/authenticate/", "/oidc/authenticate/", "/api/v1.0/users/me/") _WHOAMI = "/api/v1.0/users/me/" class OidcSession: """A cookie-carrying HTTP session logged in via the app's OIDC authorization-code flow.""" def __init__(self, base: str): self.base = base.rstrip("/") self.jar = http.cookiejar.CookieJar() self.opener = urllib.request.build_opener( urllib.request.HTTPCookieProcessor(self.jar), urllib.request.HTTPSHandler(context=_CTX), ) # -- low-level --------------------------------------------------------------------------- def _open( self, url: str, data: bytes | None = None, headers: dict[str, str] | None = None, method: str | None = None, timeout: int = 30, ) -> tuple[int, str, bytes]: """Open a URL (following redirects, carrying cookies). Returns (status, final_url, body).""" req = urllib.request.Request(url, data=data, method=method) for k, v in (headers or {}).items(): req.add_header(k, v) try: with self.opener.open(req, timeout=timeout) as resp: return resp.getcode(), resp.geturl(), resp.read() except urllib.error.HTTPError as e: body = b"" with contextlib.suppress(Exception): body = e.read() return e.code, e.filename or url, body def _csrf_token(self) -> str | None: for c in self.jar: if "csrftoken" in c.name.lower(): return c.value return None # -- login ------------------------------------------------------------------------------- def login( self, username: str, password: str, login_paths: tuple[str, ...] = _LOGIN_PATHS, whoami: str = _WHOAMI, ) -> dict: """OIDC authorization-code login: app → keycloak form → callback → session cookie. Asserts the resulting session GETs `whoami` with HTTP 200 and returns the parsed user. """ page, page_url, last = None, None, (0, "", b"") for path in login_paths: status, final_url, body = self._open(self.base + path) last = (status, final_url, body) text = body.decode(errors="replace") if "kc-form-login" in text or ( "/protocol/openid-connect/" in final_url and "