# -*- coding: utf-8 -*- """RC V7: Office -> Chat/Kontakte Single-Sign-On ueber den lokalen Test-Proxy. Root Cause: der Test-Proxy folgte 3xx-Redirects selbst (Default-urllib-Opener). Beim GET /empfang/shell/launch (302 + Set-Cookie aza_session) wurde dadurch der Einmal-Token serverseitig verbraucht und das Set-Cookie verschluckt -> die WebView blieb unauthentifiziert -> Loginmaske in Chat UND Kontakte. Fix: _PROXY_OPENER folgt 3xx NICHT mehr; die 302 (Location lokal, Set-Cookie weitergereicht) geht an die WebView, die dem Redirect selbst folgt. _localize_set_cookie macht das Cookie fuer 127.0.0.1/HTTP annehmbar (Domain/Secure nur im Testmodus entfernt). Der Test startet einen Fake-Upstream, der /shell/launch wie das echte Backend beantwortet, und prueft den Proxy end-to-end. """ from __future__ import annotations import http.cookiejar import inspect import json import os import threading import unittest import urllib.error import urllib.request from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer from pathlib import Path ROOT = Path(__file__).resolve().parent _SESSION_VALUE = "sess-abc123-secret" _USER = {"user_id": "u-andre-001", "practice_id": "p-lindengut-007", "display_name": "Andre M. Surovy"} _used_tokens = set() _used_lock = threading.Lock() class _FakeUpstreamHandler(BaseHTTPRequestHandler): protocol_version = "HTTP/1.1" def log_message(self, *a): pass def _json(self, code, payload): body = json.dumps(payload).encode("utf-8") self.send_response(code) self.send_header("Content-Type", "application/json") self.send_header("Content-Length", str(len(body))) self.end_headers() self.wfile.write(body) def do_GET(self): if self.path.startswith("/empfang/shell/launch"): # Token-Query auslesen tok = "" if "token=" in self.path: tok = self.path.split("token=", 1)[1].split("&", 1)[0] with _used_lock: if not tok or tok in _used_tokens: self._json(401, {"detail": "Shell-Token ungueltig/verbraucht"}) return _used_tokens.add(tok) # 302 + Set-Cookie wie echtes Backend (mit Domain + Secure, mehrere Cookies) self.send_response(302) self.send_header("Location", "/empfang/?empfang_chat_shell=1&shell_source=empfang_chat_shell") self.send_header( "Set-Cookie", f"aza_session={_SESSION_VALUE}; HttpOnly; Path=/; SameSite=Lax; Secure; Domain=api.aza-medwork.ch", ) self.send_header("Set-Cookie", "aza_csrf=csrf-xyz; Path=/; SameSite=Lax") self.send_header("Content-Length", "0") self.end_headers() return if self.path.startswith("/empfang/shell/context/me") or self.path.startswith("/auth/me"): cookie = self.headers.get("Cookie") or "" if f"aza_session={_SESSION_VALUE}" in cookie: self._json(200, {"authenticated": True, "success": True, **_USER}) else: self._json(401, {"detail": "nicht angemeldet"}) return self._json(404, {"detail": "nf"}) def _no_redirect_opener(): class _NR(urllib.request.HTTPRedirectHandler): def redirect_request(self, *a, **k): return None return urllib.request.build_opener(_NR) class OfficeChatContactsSSOTests(unittest.TestCase): @classmethod def setUpClass(cls): os.environ["AZA_DOKU_PROMPT_TEST"] = "1" _used_tokens.clear() cls.upstream = ThreadingHTTPServer(("127.0.0.1", 0), _FakeUpstreamHandler) cls.up_port = cls.upstream.server_address[1] threading.Thread(target=cls.upstream.serve_forever, daemon=True).start() os.environ["AZA_EMPFANG_TEST_UPSTREAM"] = f"http://127.0.0.1:{cls.up_port}" import aza_empfang_test_html_proxy as px cls.px = px cls.srv, cls.port = px._start_server_on_port(0) cls.base = f"http://127.0.0.1:{cls.port}" @classmethod def tearDownClass(cls): try: cls.srv.shutdown() cls.srv.server_close() except Exception: pass try: cls.upstream.shutdown() cls.upstream.server_close() except Exception: pass # --- Root-Cause-Beweis + Fix: Proxy folgt 302 NICHT, reicht Cookie durch --- def _launch_once(self, token): opener = _no_redirect_opener() req = urllib.request.Request(f"{self.base}/empfang/shell/launch?token={token}&target=empfang_chat_shell") try: resp = opener.open(req, timeout=8) return resp.status, resp.headers except urllib.error.HTTPError as e: return e.code, e.headers def test_launch_returns_302_not_followed(self): status, headers = self._launch_once("tok-chat-1") self.assertEqual(status, 302, "Proxy darf den Redirect nicht selbst folgen") loc = headers.get("Location") or "" self.assertTrue(loc.startswith(self.base + "/empfang/"), f"Location nicht lokal: {loc}") self.assertIn("empfang_chat_shell=1", loc) def test_set_cookie_forwarded_and_localized(self): status, headers = self._launch_once("tok-chat-2") cookies = headers.get_all("Set-Cookie") or [] joined = " || ".join(cookies) self.assertIn("aza_session=" + _SESSION_VALUE, joined) # Mehrere Set-Cookie bleiben erhalten self.assertTrue(any(c.startswith("aza_csrf=") for c in cookies), f"2. Cookie fehlt: {cookies}") # Domain (Live) und Secure (ueber HTTP) im Testmodus entfernt self.assertNotIn("domain=", joined.lower()) self.assertNotIn("secure", joined.lower()) # Schutzattribute bleiben self.assertIn("HttpOnly", joined) self.assertIn("SameSite", joined) def test_context_me_authenticated_after_launch(self): # 1) Launch -> Cookie holen _status, headers = self._launch_once("tok-chat-3") session_cookie = "" for c in headers.get_all("Set-Cookie") or []: if c.startswith("aza_session="): session_cookie = c.split(";", 1)[0] break self.assertTrue(session_cookie, "kein aza_session-Cookie erhalten") # 2) context/me mit Cookie ueber den Proxy -> derselbe Benutzer wie Office req = urllib.request.Request( f"{self.base}/empfang/shell/context/me", headers={"Cookie": session_cookie}, ) with urllib.request.urlopen(req, timeout=8) as r: self.assertEqual(r.status, 200) data = json.loads(r.read().decode("utf-8")) self.assertEqual(data.get("user_id"), _USER["user_id"]) self.assertEqual(data.get("practice_id"), _USER["practice_id"]) self.assertEqual(data.get("display_name"), _USER["display_name"]) def test_context_me_401_without_cookie(self): try: with urllib.request.urlopen(f"{self.base}/empfang/shell/context/me", timeout=8) as r: code = r.status except urllib.error.HTTPError as e: code = e.code self.assertEqual(code, 401) def test_token_is_one_time(self): s1, _ = self._launch_once("tok-once") s2, _ = self._launch_once("tok-once") self.assertEqual(s1, 302) self.assertEqual(s2, 401, "verbrauchter Token muss abgelehnt werden") # --- Einheiten: Cookie-Lokalisierung + No-Redirect-Opener --- def test_localize_set_cookie_strips_domain_and_secure(self): h = self.px._EmpfangTestProxyHandler inst = h.__new__(h) out = inst._localize_set_cookie( "aza_session=V; HttpOnly; Path=/; SameSite=Lax; Secure; Domain=api.aza-medwork.ch" ) self.assertIn("aza_session=V", out) self.assertIn("HttpOnly", out) self.assertIn("SameSite=Lax", out) self.assertIn("Path=/", out) self.assertNotIn("Domain", out) self.assertNotIn("Secure", out) def test_localize_set_cookie_keeps_plain_cookie(self): h = self.px._EmpfangTestProxyHandler inst = h.__new__(h) out = inst._localize_set_cookie("aza_session=V; HttpOnly; Path=/; SameSite=Lax") self.assertEqual(out, "aza_session=V; HttpOnly; Path=/; SameSite=Lax") def test_proxy_does_not_follow_redirects(self): self.assertIsNone( self.px._NoRedirectHandler.redirect_request( self.px._NoRedirectHandler(), None, None, 302, "", {}, "http://x/" ) ) def test_proxy_does_not_log_requests(self): src = inspect.getsource(self.px._EmpfangTestProxyHandler.log_message) self.assertIn("pass", src) class ServerSideHandoffSourceTests(unittest.TestCase): """Office bleibt Quelle der Wahrheit; Token einmalig + an user_id/practice_id gebunden.""" @classmethod def setUpClass(cls): cls.src = (ROOT / "empfang_routes.py").read_text(encoding="utf-8") def test_token_one_time_consumed(self): self.assertIn("del _shell_store[stok]", self.src) def test_launch_sets_httponly_lax_cookie(self): self.assertIn('"aza_session"', self.src) self.assertIn("httponly=True", self.src) self.assertIn('samesite="lax"', self.src) def test_token_bound_to_user_and_practice(self): self.assertIn('"user_id": uid', self.src) self.assertIn('"practice_id": pid', self.src) self.assertIn('"purpose": _SHELL_PURPOSE', self.src) if __name__ == "__main__": unittest.main()