diff --git a/tests/lasuite-docs/custom/_oidc_session.py b/tests/lasuite-docs/custom/_oidc_session.py new file mode 100644 index 0000000..312c998 --- /dev/null +++ b/tests/lasuite-docs/custom/_oidc_session.py @@ -0,0 +1,158 @@ +"""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 "