FreeRDP
Loading...
Searching...
No Matches
test_qt_aad_auth_helper.py
1#!/usr/bin/env python3
2# FreeRDP: A Remote Desktop Protocol Implementation
3# Integration test for the out-of-process Qt-based AAD auth helper
4#
5# Copyright 2026 David Fort <contact@hardening-consulting.com>
6#
7# Licensed under the Apache License, Version 2.0 (the "License");
8# you may not use this file except in compliance with the License.
9# You may obtain a copy of the License at
10#
11# http://www.apache.org/licenses/LICENSE-2.0
12#
13# Unless required by applicable law or agreed to in writing, software
14# distributed under the License is distributed on an "AS IS" BASIS,
15# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
16# See the License for the specific language governing permissions and
17# limitations under the License.
18"""
19Drives the real freerdp-qt-aad-helper binary over its JSON-RPC protocol (see
20client/common/aad-auth-helper-protocol.md) against a local HTTP redirect fixture, under
21QT_QPA_PLATFORM=offscreen. The protocol travels over a dedicated pair of pipes handed to the
22helper via --cmdInFd=/--cmdOutFd= command line arguments (not stdin/stdout - see
23client/common/aad_helper.c), so this test builds those pipes itself and passes them the same way
24FreeRDP does (see HelperProcess below for the platform-specific handle encoding/inheritance).
25
26This exists to catch, as a regression test, two bugs found while developing the helper: AAD's
27native-broker redirect_uri uses the non-standard "ms-appx-web" scheme, which QtWebEngine (1)
28never surfaces to navigationRequested() at all unless the scheme is registered first (Chromium
29instead hands it off to the desktop environment and the redirect is lost), and (2) - even once
30registered - refused with net::ERR_UNSAFE_REDIRECT to let a real https-shaped redirect into it
31unless the scheme is also flagged CORS-enabled (see kBrokerScheme's comment in main.cpp for
32both). A plain http(s) redirect_uri is exercised too, as a regression check that fix didn't
33break the common case.
34
35If the helper doesn't answer the initial "hello" handshake at all, the environment is assumed
36unable to run Qt WebEngine (e.g. a minimal CI image missing GL/X11 libs) and the test is skipped
37rather than failed.
38"""
39import http.server
40import json
41import os
42import queue
43import subprocess
44import sys
45import threading
46import time
47
48if sys.platform == "win32":
49 import msvcrt
50
51HELLO_TIMEOUT = 20
52REQUEST_TIMEOUT = 20
53SKIP_EXIT_CODE = 125
54
55BROKER_TARGET = "ms-appx-web://microsoft.aad.brokerplugin/test-client-id?code=TESTCODE123"
56
57
58class RedirectHandler(http.server.BaseHTTPRequestHandler):
59 def do_GET(self):
60 self.server.last_user_agent = self.headers.get("User-Agent")
61 if self.path.startswith("/redirect"):
62 self.send_response(302)
63 self.send_header("Location", BROKER_TARGET)
64 self.end_headers()
65 else:
66 self.send_response(200)
67 self.send_header("Content-Type", "text/html")
68 self.end_headers()
69 self.wfile.write(b"<html><body>ok</body></html>")
70
71 def log_message(self, fmt, *args):
72 pass
73
74
75class HelperProcess:
76 def __init__(self, helper_path, env):
77 # the JSON-RPC channel: two pipes, their read/write ends handed to the helper via
78 # --cmdInFd=/--cmdOutFd= - not stdin/stdout, which are left as a normal passthrough so
79 # the helper's own Qt/Chromium diagnostic output doesn't collide with the protocol. The
80 # value must be encoded exactly like winpr_exportHandleToString() does it (see
81 # winpr/libwinpr/handle/handle.c), which differs by platform: on POSIX it's a 'P' type
82 # tag followed by the fd in hex; on Windows (no type tag there) it's the native HANDLE
83 # value in hex - a Windows CRT fd from os.pipe() is not that HANDLE, so it must be
84 # resolved via msvcrt.get_osfhandle() first, and marked inheritable since Windows has no
85 # equivalent of POSIX's pass_fds.
86 cmd_in_r, cmd_in_w = os.pipe()
87 cmd_out_r, cmd_out_w = os.pipe()
88
89 if sys.platform == "win32":
90 cmd_in_handle = msvcrt.get_osfhandle(cmd_in_r)
91 cmd_out_handle = msvcrt.get_osfhandle(cmd_out_w)
92 os.set_handle_inheritable(cmd_in_handle, True)
93 os.set_handle_inheritable(cmd_out_handle, True)
94 cmd_in_arg = f"--cmdInFd={cmd_in_handle:x}"
95 cmd_out_arg = f"--cmdOutFd={cmd_out_handle:x}"
96 # pass_fds is POSIX-only; close_fds=False lets CreateProcess(bInheritHandles=TRUE)
97 # inherit every inheritable handle in this process, which - since os.pipe() creates
98 # non-inheritable handles by default and only the two above were just flipped to
99 # inheritable - is just these two.
100 popen_kwargs = {"close_fds": False}
101 else:
102 cmd_in_arg = f"--cmdInFd=P{cmd_in_r:x}"
103 cmd_out_arg = f"--cmdOutFd=P{cmd_out_w:x}"
104 popen_kwargs = {"close_fds": True, "pass_fds": (cmd_in_r, cmd_out_w)}
105
106 self.proc = subprocess.Popen(
107 [helper_path, cmd_in_arg, cmd_out_arg],
108 stdin=subprocess.DEVNULL,
109 stdout=None,
110 stderr=None,
111 env=env,
112 **popen_kwargs,
113 )
114 os.close(cmd_in_r)
115 os.close(cmd_out_w)
116 self._in = os.fdopen(cmd_in_w, "w", buffering=1)
117 self._out = os.fdopen(cmd_out_r, "r")
118 self._lines = queue.Queue()
119 self._reader = threading.Thread(target=self._read_loop, daemon=True)
120 self._reader.start()
121 self._next_id = 0
122
123 def _read_loop(self):
124 for line in self._out:
125 line = line.strip()
126 if line:
127 self._lines.put(line)
128
129 def request(self, method, params=None, timeout=REQUEST_TIMEOUT):
130 self._next_id += 1
131 req_id = self._next_id
132 msg = {"jsonrpc": "2.0", "id": req_id, "method": method}
133 if params is not None:
134 msg["params"] = params
135 self._in.write(json.dumps(msg) + "\n")
136 self._in.flush()
137
138 deadline = time.monotonic() + timeout
139 while True:
140 remaining = deadline - time.monotonic()
141 if remaining <= 0:
142 break
143 try:
144 line = self._lines.get(timeout=remaining)
145 except queue.Empty:
146 break
147 reply = json.loads(line)
148 if reply.get("id") == req_id:
149 return reply
150 # notification or stray reply for a different id: ignore, keep waiting
151 raise TimeoutError(f"no reply to {method!r} within {timeout}s")
152
153 def notify(self, method, params=None):
154 msg = {"jsonrpc": "2.0", "method": method}
155 if params is not None:
156 msg["params"] = params
157 self._in.write(json.dumps(msg) + "\n")
158 self._in.flush()
159
160 def wait(self, timeout=10):
161 return self.proc.wait(timeout=timeout)
162
163
164def skip(message):
165 print(f"SKIP: {message}")
166 sys.exit(SKIP_EXIT_CODE)
167
168
169def fail(message):
170 print(f"FAIL: {message}")
171 sys.exit(1)
172
173
174def main():
175 if len(sys.argv) != 2:
176 fail("usage: test_qt_aad_auth_helper.py <path-to-freerdp-qt-aad-helper>")
177 helper_path = sys.argv[1]
178 if not os.path.isfile(helper_path):
179 fail(f"helper binary not found: {helper_path}")
180
181 server = http.server.HTTPServer(("127.0.0.1", 0), RedirectHandler)
182 server.last_user_agent = None
183 port = server.server_port
184 server_thread = threading.Thread(target=server.serve_forever, daemon=True)
185 server_thread.start()
186
187 env = dict(os.environ)
188 env["QT_QPA_PLATFORM"] = "offscreen"
189 env["QT_QUICK_BACKEND"] = "software"
190 env["QTWEBENGINE_CHROMIUM_FLAGS"] = "--disable-gpu --disable-software-rasterizer"
191
192 helper = HelperProcess(helper_path, env)
193 try:
194 try:
195 hello = helper.request(
196 "hello", {"protocol_version": 1, "client": "freerdp"}, timeout=HELLO_TIMEOUT
197 )
198 except TimeoutError:
199 skip("helper did not answer 'hello' - environment likely can't run Qt WebEngine")
200 if "error" in hello:
201 fail(f"hello returned an error: {hello['error']}")
202 print(f"hello ok: {hello['result']}")
203
204 # Case 1: real HTTP redirect into AAD's ms-appx-web native-broker scheme - the exact
205 # shape that broke twice during development (scheme not observed at all, then
206 # ERR_UNSAFE_REDIRECT).
207 nav = helper.request(
208 "navigate",
209 {
210 "title": "test",
211 "url": f"http://127.0.0.1:{port}/redirect",
212 "redirect_uri": "ms-appx-web://microsoft.aad.brokerplugin/test-client-id",
213 "timeout_ms": REQUEST_TIMEOUT * 1000,
214 },
215 )
216 if "error" in nav:
217 fail(f"navigate (broker redirect) failed: {nav['error']}")
218 got = nav["result"].get("redirect_url")
219 if got != BROKER_TARGET:
220 fail(f"navigate (broker redirect): expected {BROKER_TARGET!r}, got {got!r}")
221 print("broker redirect ok")
222
223 # Case 2: plain http(s)-shaped redirect_uri - regression check, must keep working.
224 plain_url = f"http://127.0.0.1:{port}/"
225 nav2 = helper.request(
226 "navigate",
227 {
228 "title": "test",
229 "url": plain_url,
230 "redirect_uri": plain_url,
231 "timeout_ms": REQUEST_TIMEOUT * 1000,
232 },
233 )
234 if "error" in nav2:
235 fail(f"navigate (plain redirect_uri) failed: {nav2['error']}")
236 got2 = nav2["result"].get("redirect_url")
237 if got2 != plain_url:
238 fail(f"navigate (plain redirect_uri): expected {plain_url!r}, got {got2!r}")
239 print("plain redirect_uri ok")
240
241 # Case 3: no user_agent supplied -> native (QtWebEngine) UA is used. Records the native
242 # UA that Case 5/6 assert is restored after an override.
243 default_nav = helper.request(
244 "navigate",
245 {
246 "title": "test",
247 "url": f"http://127.0.0.1:{port}/redirect",
248 "redirect_uri": "ms-appx-web://microsoft.aad.brokerplugin/test-client-id",
249 "timeout_ms": REQUEST_TIMEOUT * 1000,
250 },
251 )
252 if "error" in default_nav:
253 fail(f"navigate (default UA) failed: {default_nav['error']}")
254 default_ua = server.last_user_agent
255 if not default_ua:
256 fail("server saw no User-Agent for the default case")
257 if "TestAgent/1.0" in default_ua:
258 fail(f"default UA unexpectedly equals the override: {default_ua!r}")
259 print(f"default UA ok: {default_ua}")
260
261 # Case 4: user_agent supplied -> helper presents it verbatim.
262 override_ua = "TestAgent/1.0"
263 ua_nav = helper.request(
264 "navigate",
265 {
266 "title": "test",
267 "url": f"http://127.0.0.1:{port}/redirect",
268 "redirect_uri": "ms-appx-web://microsoft.aad.brokerplugin/test-client-id",
269 "user_agent": override_ua,
270 "timeout_ms": REQUEST_TIMEOUT * 1000,
271 },
272 )
273 if "error" in ua_nav:
274 fail(f"navigate (override UA) failed: {ua_nav['error']}")
275 if server.last_user_agent != override_ua:
276 fail(f"expected UA {override_ua!r}, server saw {server.last_user_agent!r}")
277 print("override UA ok")
278
279 # Case 5: an explicitly empty user_agent must also restore the native UA (contract:
280 # absent OR empty means native, even after a previous override).
281 empty_nav = helper.request(
282 "navigate",
283 {
284 "title": "test",
285 "url": f"http://127.0.0.1:{port}/redirect",
286 "redirect_uri": "ms-appx-web://microsoft.aad.brokerplugin/test-client-id",
287 "user_agent": "",
288 "timeout_ms": REQUEST_TIMEOUT * 1000,
289 },
290 )
291 if "error" in empty_nav:
292 fail(f"navigate (empty UA) failed: {empty_nav['error']}")
293 if server.last_user_agent != default_ua:
294 fail(
295 f"expected native UA after empty override {default_ua!r}, "
296 f"saw {server.last_user_agent!r}"
297 )
298 print("empty UA restore ok")
299
300 # Case 6: override again, then omit -> native UA restored. Covers the omitted path
301 # transitioning from an override, not only from the native state Case 5 left behind.
302 reoverride_nav = helper.request(
303 "navigate",
304 {
305 "title": "test",
306 "url": f"http://127.0.0.1:{port}/redirect",
307 "redirect_uri": "ms-appx-web://microsoft.aad.brokerplugin/test-client-id",
308 "user_agent": override_ua,
309 "timeout_ms": REQUEST_TIMEOUT * 1000,
310 },
311 )
312 if "error" in reoverride_nav:
313 fail(f"navigate (re-override UA) failed: {reoverride_nav['error']}")
314 omitted_nav = helper.request(
315 "navigate",
316 {
317 "title": "test",
318 "url": f"http://127.0.0.1:{port}/redirect",
319 "redirect_uri": "ms-appx-web://microsoft.aad.brokerplugin/test-client-id",
320 "timeout_ms": REQUEST_TIMEOUT * 1000,
321 },
322 )
323 if "error" in omitted_nav:
324 fail(f"navigate (omitted UA) failed: {omitted_nav['error']}")
325 if server.last_user_agent != default_ua:
326 fail(f"expected native UA restored {default_ua!r}, server saw {server.last_user_agent!r}")
327 print("omitted UA restore ok")
328
329 shut = helper.request("shutdown")
330 if shut.get("result") is not None:
331 fail(f"shutdown expected a null result, got {shut}")
332 helper.notify("exit")
333
334 rc = helper.wait(timeout=10)
335 if rc != 0:
336 fail(f"helper exited with code {rc}, expected 0")
337 print("shutdown/exit ok")
338 finally:
339 server.shutdown()
340 if helper.proc.poll() is None:
341 helper.proc.kill()
342
343 print("PASS")
344
345
346if __name__ == "__main__":
347 main()