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 if self.path.startswith("/redirect"):
61 self.send_response(302)
62 self.send_header("Location", BROKER_TARGET)
63 self.end_headers()
64 else:
65 self.send_response(200)
66 self.send_header("Content-Type", "text/html")
67 self.end_headers()
68 self.wfile.write(b"<html><body>ok</body></html>")
69
70 def log_message(self, fmt, *args):
71 pass
72
73
74class HelperProcess:
75 def __init__(self, helper_path, env):
76 # the JSON-RPC channel: two pipes, their read/write ends handed to the helper via
77 # --cmdInFd=/--cmdOutFd= - not stdin/stdout, which are left as a normal passthrough so
78 # the helper's own Qt/Chromium diagnostic output doesn't collide with the protocol. The
79 # value must be encoded exactly like winpr_exportHandleToString() does it (see
80 # winpr/libwinpr/handle/handle.c), which differs by platform: on POSIX it's a 'P' type
81 # tag followed by the fd in hex; on Windows (no type tag there) it's the native HANDLE
82 # value in hex - a Windows CRT fd from os.pipe() is not that HANDLE, so it must be
83 # resolved via msvcrt.get_osfhandle() first, and marked inheritable since Windows has no
84 # equivalent of POSIX's pass_fds.
85 cmd_in_r, cmd_in_w = os.pipe()
86 cmd_out_r, cmd_out_w = os.pipe()
87
88 if sys.platform == "win32":
89 cmd_in_handle = msvcrt.get_osfhandle(cmd_in_r)
90 cmd_out_handle = msvcrt.get_osfhandle(cmd_out_w)
91 os.set_handle_inheritable(cmd_in_handle, True)
92 os.set_handle_inheritable(cmd_out_handle, True)
93 cmd_in_arg = f"--cmdInFd={cmd_in_handle:x}"
94 cmd_out_arg = f"--cmdOutFd={cmd_out_handle:x}"
95 # pass_fds is POSIX-only; close_fds=False lets CreateProcess(bInheritHandles=TRUE)
96 # inherit every inheritable handle in this process, which - since os.pipe() creates
97 # non-inheritable handles by default and only the two above were just flipped to
98 # inheritable - is just these two.
99 popen_kwargs = {"close_fds": False}
100 else:
101 cmd_in_arg = f"--cmdInFd=P{cmd_in_r:x}"
102 cmd_out_arg = f"--cmdOutFd=P{cmd_out_w:x}"
103 popen_kwargs = {"close_fds": True, "pass_fds": (cmd_in_r, cmd_out_w)}
104
105 self.proc = subprocess.Popen(
106 [helper_path, cmd_in_arg, cmd_out_arg],
107 stdin=subprocess.DEVNULL,
108 stdout=None,
109 stderr=None,
110 env=env,
111 **popen_kwargs,
112 )
113 os.close(cmd_in_r)
114 os.close(cmd_out_w)
115 self._in = os.fdopen(cmd_in_w, "w", buffering=1)
116 self._out = os.fdopen(cmd_out_r, "r")
117 self._lines = queue.Queue()
118 self._reader = threading.Thread(target=self._read_loop, daemon=True)
119 self._reader.start()
120 self._next_id = 0
121
122 def _read_loop(self):
123 for line in self._out:
124 line = line.strip()
125 if line:
126 self._lines.put(line)
127
128 def request(self, method, params=None, timeout=REQUEST_TIMEOUT):
129 self._next_id += 1
130 req_id = self._next_id
131 msg = {"jsonrpc": "2.0", "id": req_id, "method": method}
132 if params is not None:
133 msg["params"] = params
134 self._in.write(json.dumps(msg) + "\n")
135 self._in.flush()
136
137 deadline = time.monotonic() + timeout
138 while True:
139 remaining = deadline - time.monotonic()
140 if remaining <= 0:
141 break
142 try:
143 line = self._lines.get(timeout=remaining)
144 except queue.Empty:
145 break
146 reply = json.loads(line)
147 if reply.get("id") == req_id:
148 return reply
149 # notification or stray reply for a different id: ignore, keep waiting
150 raise TimeoutError(f"no reply to {method!r} within {timeout}s")
151
152 def notify(self, method, params=None):
153 msg = {"jsonrpc": "2.0", "method": method}
154 if params is not None:
155 msg["params"] = params
156 self._in.write(json.dumps(msg) + "\n")
157 self._in.flush()
158
159 def wait(self, timeout=10):
160 return self.proc.wait(timeout=timeout)
161
162
163def skip(message):
164 print(f"SKIP: {message}")
165 sys.exit(SKIP_EXIT_CODE)
166
167
168def fail(message):
169 print(f"FAIL: {message}")
170 sys.exit(1)
171
172
173def main():
174 if len(sys.argv) != 2:
175 fail("usage: test_qt_aad_auth_helper.py <path-to-freerdp-qt-aad-helper>")
176 helper_path = sys.argv[1]
177 if not os.path.isfile(helper_path):
178 fail(f"helper binary not found: {helper_path}")
179
180 server = http.server.HTTPServer(("127.0.0.1", 0), RedirectHandler)
181 port = server.server_port
182 server_thread = threading.Thread(target=server.serve_forever, daemon=True)
183 server_thread.start()
184
185 env = dict(os.environ)
186 env["QT_QPA_PLATFORM"] = "offscreen"
187 env["QT_QUICK_BACKEND"] = "software"
188 env["QTWEBENGINE_CHROMIUM_FLAGS"] = "--disable-gpu --disable-software-rasterizer"
189
190 helper = HelperProcess(helper_path, env)
191 try:
192 try:
193 hello = helper.request(
194 "hello", {"protocol_version": 1, "client": "freerdp"}, timeout=HELLO_TIMEOUT
195 )
196 except TimeoutError:
197 skip("helper did not answer 'hello' - environment likely can't run Qt WebEngine")
198 if "error" in hello:
199 fail(f"hello returned an error: {hello['error']}")
200 print(f"hello ok: {hello['result']}")
201
202 # Case 1: real HTTP redirect into AAD's ms-appx-web native-broker scheme - the exact
203 # shape that broke twice during development (scheme not observed at all, then
204 # ERR_UNSAFE_REDIRECT).
205 nav = helper.request(
206 "navigate",
207 {
208 "title": "test",
209 "url": f"http://127.0.0.1:{port}/redirect",
210 "redirect_uri": "ms-appx-web://microsoft.aad.brokerplugin/test-client-id",
211 "timeout_ms": REQUEST_TIMEOUT * 1000,
212 },
213 )
214 if "error" in nav:
215 fail(f"navigate (broker redirect) failed: {nav['error']}")
216 got = nav["result"].get("redirect_url")
217 if got != BROKER_TARGET:
218 fail(f"navigate (broker redirect): expected {BROKER_TARGET!r}, got {got!r}")
219 print("broker redirect ok")
220
221 # Case 2: plain http(s)-shaped redirect_uri - regression check, must keep working.
222 plain_url = f"http://127.0.0.1:{port}/"
223 nav2 = helper.request(
224 "navigate",
225 {
226 "title": "test",
227 "url": plain_url,
228 "redirect_uri": plain_url,
229 "timeout_ms": REQUEST_TIMEOUT * 1000,
230 },
231 )
232 if "error" in nav2:
233 fail(f"navigate (plain redirect_uri) failed: {nav2['error']}")
234 got2 = nav2["result"].get("redirect_url")
235 if got2 != plain_url:
236 fail(f"navigate (plain redirect_uri): expected {plain_url!r}, got {got2!r}")
237 print("plain redirect_uri ok")
238
239 shut = helper.request("shutdown")
240 if shut.get("result") is not None:
241 fail(f"shutdown expected a null result, got {shut}")
242 helper.notify("exit")
243
244 rc = helper.wait(timeout=10)
245 if rc != 0:
246 fail(f"helper exited with code {rc}, expected 0")
247 print("shutdown/exit ok")
248 finally:
249 server.shutdown()
250 if helper.proc.poll() is None:
251 helper.proc.kill()
252
253 print("PASS")
254
255
256if __name__ == "__main__":
257 main()