FreeRDP
Loading...
Searching...
No Matches
xdg-aad-helper.py
1#!/usr/bin/env python3
2""" XDG "native browser" AAD auth helper.
3
4
5"""
6import json
7import os
8import shutil
9import socket
10import subprocess
11import sys
12import threading
13import time
14import urllib.parse
15import uuid
16
17DEFAULT_TIMEOUT_MS = 180000
18BROKER_SCHEME = "ms-appx-web://"
19DESKTOP_FILE_NAME = "freerdp-aad-handler.desktop"
20
21
22def locate_handler_path() -> str | None:
23 """Finds the absolute path to freerdp-aad-handler, the script the OS invokes for
24 ms-appx-web:// redirects: first right next to this script (where an install puts it, and
25 also where it sits when both scripts are just run straight out of this source directory),
26 then falls back to PATH."""
27 here = os.path.dirname(os.path.abspath(__file__))
28 for name in ("freerdp-aad-handler", "freerdp_aad_handler.py"):
29 candidate = os.path.join(here, name)
30 if os.path.isfile(candidate):
31 return candidate
32 return shutil.which("freerdp-aad-handler")
33
34
35def build_desktop_entry(handler_path: str) -> bytes:
36 return (
37 "[Desktop Entry]\n"
38 "Type=Application\n"
39 "Name=FreeRDP AAD redirect handler\n"
40 f"Exec={handler_path} %u\n"
41 "MimeType=x-scheme-handler/ms-appx-web;\n"
42 "NoDisplay=true\n"
43 "StartupNotify=false\n"
44 "Terminal=false\n"
45 ).encode()
46
47
48def ensure_handler_installed() -> None:
49 """Best-effort: makes sure freerdp-aad-handler.desktop is present in the user's own
50 applications directory and registered as the default handler for
51 x-scheme-handler/ms-appx-web, so the IdP's ms-appx-web:// broker redirect actually gets
52 routed back to us. Generates the .desktop content itself (rather than depending on a
53 CMake-installed template) so this self-heals regardless of packaging - including when run
54 straight out of the build tree, with no `cmake --install` ever having happened. Failures
55 here are logged but not fatal: navigate() will simply time out later for ms-appx-web://
56 redirects if registration didn't take."""
57 handler_path = locate_handler_path()
58 if not handler_path:
59 print("[xdg-aad-helper] warning: could not locate freerdp-aad-handler next to this "
60 "script or on PATH - ms-appx-web:// redirects will not be caught", file=sys.stderr)
61 return
62
63 data_home = os.environ.get("XDG_DATA_HOME") or os.path.join(
64 os.path.expanduser("~"), ".local", "share")
65 apps_dir = os.path.join(data_home, "applications")
66 dst = os.path.join(apps_dir, DESKTOP_FILE_NAME)
67 desired = build_desktop_entry(handler_path)
68
69 try:
70 os.makedirs(apps_dir, exist_ok=True)
71 current = None
72 if os.path.isfile(dst):
73 with open(dst, "rb") as f:
74 current = f.read()
75 if current != desired:
76 with open(dst, "wb") as f:
77 f.write(desired)
78 subprocess.run(["update-desktop-database", apps_dir], check=False,
79 capture_output=True)
80 except OSError as exc:
81 print(f"[xdg-aad-helper] warning: could not install {dst!r}: {exc}", file=sys.stderr)
82 return
83
84 try:
85 query = subprocess.run(
86 ["xdg-mime", "query", "default", "x-scheme-handler/ms-appx-web"],
87 check=False, capture_output=True, text=True)
88 if query.stdout.strip() != DESKTOP_FILE_NAME:
89 subprocess.run(["xdg-mime", "default", DESKTOP_FILE_NAME,
90 "x-scheme-handler/ms-appx-web"], check=False)
91 except OSError as exc:
92 print(f"[xdg-aad-helper] warning: could not register as the default handler for "
93 f"x-scheme-handler/ms-appx-web: {exc}", file=sys.stderr)
94
95
96def import_handle(arg_value: str) -> int:
97 """Reverses winpr_exportHandleToString() for the POSIX pipe case: the
98 value is 'P<hex-fd>' ('P' = HANDLE_TYPE_ANONYMOUS_PIPE, see
99 winpr/libwinpr/handle/handle.c)."""
100 if not arg_value.startswith("P"):
101 raise ValueError(f"unsupported handle type tag in {arg_value!r}")
102 return int(arg_value[1:], 16)
103
104
106 """Newline-delimited JSON over the raw cmdIn/cmdOut pipe fds."""
107
108 def __init__(self, fd_in: int, fd_out: int):
109 self._in = os.fdopen(fd_in, "rb", buffering=0)
110 self._out = os.fdopen(fd_out, "wb", buffering=0)
111 self._out_lock = threading.Lock()
112 self._buf = b""
113
114 def read_message(self):
115 """Returns the next parsed JSON object, {} for an unparsable line
116 (silently ignored per the protocol notes), or None on EOF."""
117 while b"\n" not in self._buf:
118 chunk = self._in.read(4096)
119 if not chunk:
120 return None
121 self._buf += chunk
122 line, _, self._buf = self._buf.partition(b"\n")
123 if not line.strip():
124 return {}
125 try:
126 return json.loads(line)
127 except ValueError:
128 return {}
129
130 def send(self, message: dict) -> None:
131 data = (json.dumps(message) + "\n").encode()
132 with self._out_lock:
133 self._out.write(data)
134
135
136def make_result(msg_id, result):
137 return {"jsonrpc": "2.0", "id": msg_id, "result": result}
138
139
140def make_error(msg_id, code, message):
141 return {"jsonrpc": "2.0", "id": msg_id, "error": {"code": code, "message": message}}
142
143
144def rewrite_state(url: str, freerdp_id: str) -> tuple[str, bool]:
145 """Prefixes the /authorize url's state query parameter with
146 "<freerdp_id>.", adding one if none was present. Returns the rewritten
147 url and whether an original state value existed (so restore_state() can
148 reverse this exactly, including the "there was no state at all" case)."""
149 parsed = urllib.parse.urlparse(url)
150 qs = urllib.parse.parse_qsl(parsed.query, keep_blank_values=True)
151 had_state = False
152 rewritten = []
153 for key, value in qs:
154 if key == "state":
155 had_state = True
156 rewritten.append((key, f"{freerdp_id}.{value}"))
157 else:
158 rewritten.append((key, value))
159 if not had_state:
160 rewritten.append(("state", freerdp_id))
161 new_query = urllib.parse.urlencode(rewritten)
162 return urllib.parse.urlunparse(parsed._replace(query=new_query)), had_state
163
164
165def restore_state(redirect_url: str, freerdp_id: str, had_original_state: bool) -> str:
166 """Reverses rewrite_state() on the redirect the IdP sent back, so FreeRDP
167 only ever sees the state value (or absence of one) it originally set."""
168 parsed = urllib.parse.urlparse(redirect_url)
169 qs = urllib.parse.parse_qsl(parsed.query, keep_blank_values=True)
170 prefix = freerdp_id + "."
171 restored = []
172 for key, value in qs:
173 if key != "state":
174 restored.append((key, value))
175 continue
176 if not had_original_state and value == freerdp_id:
177 continue # FreeRDP never set a state - drop the param entirely
178 if value.startswith(prefix):
179 restored.append((key, value[len(prefix):]))
180 else:
181 print(f"[xdgopen-aad-helper] warning: state={value!r} missing expected "
182 f"freerdp_id prefix {prefix!r}, passing through as-is", file=sys.stderr)
183 restored.append((key, value))
184 new_query = urllib.parse.urlencode(restored)
185 return urllib.parse.urlunparse(parsed._replace(query=new_query))
186
187
189 def __init__(self, freerdp_id: str):
190 self.freerdp_id = freerdp_id
191 self.had_original_state = False
192 self.cancelled = threading.Event()
193
194
196 def __init__(self, channel: JsonRpcChannel):
197 self.channel = channel
198 self.lock = threading.Lock()
199 self.current: NavigateState | None = None
200 self.shutting_down = False
201
202 def navigate(self, msg_id, params: dict) -> None:
203 with self.lock:
204 if self.shutting_down:
205 self.channel.send(make_error(msg_id, 1, "shutting_down"))
206 return
207 if self.current is not None:
208 self.channel.send(make_error(msg_id, 1, "navigate_already_in_progress"))
209 return
210 state = NavigateState(uuid.uuid4().hex[:12])
211 self.current = state
212
213 title = params.get("title", "")
214 url = params.get("url", "")
215 redirect_uri = params.get("redirect_uri", "")
216 timeout_ms = params.get("timeout_ms") or DEFAULT_TIMEOUT_MS
217
218 user_agent = params.get("user_agent", "")
219 if user_agent:
220 print("[xdgopen-aad-helper] warning: user_agent was configured, but this helper "
221 "opens the system browser and cannot override its User-Agent; ignoring "
222 "user_agent", file=sys.stderr)
223
224 if not redirect_uri.lower().startswith(BROKER_SCHEME):
225 with self.lock:
226 self.current = None
227 self.channel.send(make_error(msg_id, 1, "unsupported_redirect_scheme"))
228 return
229
230 effective_url, state.had_original_state = rewrite_state(url, state.freerdp_id)
231
232 runtime_dir = os.environ.get("XDG_RUNTIME_DIR", "/tmp")
233 sock_path = os.path.join(runtime_dir, f"freerdp_aad_{state.freerdp_id}.sock")
234 try:
235 os.unlink(sock_path)
236 except FileNotFoundError:
237 pass
238
239 server = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
240 redirect_url = None
241 error = None
242 try:
243 server.bind(sock_path)
244 os.chmod(sock_path, 0o600)
245 server.listen(1)
246 server.settimeout(0.5)
247
248 print(f"[xdgopen-aad-helper] navigate: opening browser for {effective_url!r} "
249 f"(title={title!r}), waiting on {sock_path}", file=sys.stderr)
250 subprocess.Popen(["xdg-open", effective_url])
251
252 deadline = time.monotonic() + timeout_ms / 1000.0
253 while redirect_url is None and error is None:
254 if state.cancelled.is_set():
255 error = "user_cancelled"
256 break
257 with self.lock:
258 if self.shutting_down:
259 error = "shutting_down"
260 break
261 if time.monotonic() >= deadline:
262 error = "timeout"
263 break
264 try:
265 conn, _ = server.accept()
266 except socket.timeout:
267 continue
268 with conn:
269 conn.settimeout(2.0)
270 data = b""
271 try:
272 while True:
273 chunk = conn.recv(4096)
274 if not chunk:
275 break
276 data += chunk
277 except socket.timeout:
278 pass
279 redirect_url = data.decode(errors="replace").strip()
280 finally:
281 server.close()
282 try:
283 os.unlink(sock_path)
284 except FileNotFoundError:
285 pass
286 with self.lock:
287 self.current = None
288
289 if error:
290 self.channel.send(make_error(msg_id, 1, error))
291 return
292
293 redirect_url = restore_state(redirect_url, state.freerdp_id, state.had_original_state)
294
295 parsed = urllib.parse.urlparse(redirect_url)
296 qs = urllib.parse.parse_qs(parsed.query)
297 if "error" in qs:
298 message = qs["error"][0]
299 if "error_subcode" in qs:
300 message += ": " + qs["error_subcode"][0]
301 self.channel.send(make_error(msg_id, 1, message))
302 return
303
304 self.channel.send(make_result(msg_id, {"status": "ok", "redirect_url": redirect_url}))
305
306 def cancel(self) -> None:
307 with self.lock:
308 if self.current:
309 self.current.cancelled.set()
310
311 def shutdown(self, msg_id) -> None:
312 self.channel.send(make_result(msg_id, None))
313
314 def prepare_exit(self) -> None:
315 with self.lock:
316 self.shutting_down = True
317 if self.current:
318 self.current.cancelled.set()
319
320
321def reader_loop(channel: JsonRpcChannel, session: Session) -> None:
322 while True:
323 msg = channel.read_message()
324 if msg is None:
325 break
326 if not msg:
327 continue
328
329 method = msg.get("method")
330 msg_id = msg.get("id")
331
332 if method == "hello":
333 channel.send(make_result(
334 msg_id, {"protocol_version": 1, "helper": "freerdp-xdgopen-aad-helper/1.0"}))
335 elif method == "navigate":
336 threading.Thread(target=session.navigate, args=(msg_id, msg.get("params") or {}),
337 daemon=True).start()
338 elif method == "cancel":
339 session.cancel()
340 elif method == "shutdown":
341 session.shutdown(msg_id)
342 elif method == "exit":
343 session.prepare_exit()
344 break
345 # any other method: silently ignored, per the protocol's implementer notes
346
347
348def main() -> int:
349 cmd_in_arg = None
350 cmd_out_arg = None
351 for arg in sys.argv[1:]:
352 if arg.startswith("--cmdInFd="):
353 cmd_in_arg = arg[len("--cmdInFd="):]
354 elif arg.startswith("--cmdOutFd="):
355 cmd_out_arg = arg[len("--cmdOutFd="):]
356
357 if not cmd_in_arg or not cmd_out_arg:
358 print(f"usage: {sys.argv[0]} --cmdInFd=<handle> --cmdOutFd=<handle>", file=sys.stderr)
359 return 1
360
361 try:
362 fd_in = import_handle(cmd_in_arg)
363 fd_out = import_handle(cmd_out_arg)
364 except ValueError as exc:
365 print(f"[xdgopen-aad-helper] {exc}", file=sys.stderr)
366 return 1
367
368 ensure_handler_installed()
369
370 channel = JsonRpcChannel(fd_in, fd_out)
371 session = Session(channel)
372 reader_loop(channel, session)
373 return 0
374
375
376if __name__ == "__main__":
377 raise SystemExit(main())