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 if not redirect_uri.lower().startswith(BROKER_SCHEME):
219 with self.lock:
220 self.current = None
221 self.channel.send(make_error(msg_id, 1, "unsupported_redirect_scheme"))
222 return
223
224 effective_url, state.had_original_state = rewrite_state(url, state.freerdp_id)
225
226 runtime_dir = os.environ.get("XDG_RUNTIME_DIR", "/tmp")
227 sock_path = os.path.join(runtime_dir, f"freerdp_aad_{state.freerdp_id}.sock")
228 try:
229 os.unlink(sock_path)
230 except FileNotFoundError:
231 pass
232
233 server = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
234 redirect_url = None
235 error = None
236 try:
237 server.bind(sock_path)
238 os.chmod(sock_path, 0o600)
239 server.listen(1)
240 server.settimeout(0.5)
241
242 print(f"[xdgopen-aad-helper] navigate: opening browser for {effective_url!r} "
243 f"(title={title!r}), waiting on {sock_path}", file=sys.stderr)
244 subprocess.Popen(["xdg-open", effective_url])
245
246 deadline = time.monotonic() + timeout_ms / 1000.0
247 while redirect_url is None and error is None:
248 if state.cancelled.is_set():
249 error = "user_cancelled"
250 break
251 with self.lock:
252 if self.shutting_down:
253 error = "shutting_down"
254 break
255 if time.monotonic() >= deadline:
256 error = "timeout"
257 break
258 try:
259 conn, _ = server.accept()
260 except socket.timeout:
261 continue
262 with conn:
263 conn.settimeout(2.0)
264 data = b""
265 try:
266 while True:
267 chunk = conn.recv(4096)
268 if not chunk:
269 break
270 data += chunk
271 except socket.timeout:
272 pass
273 redirect_url = data.decode(errors="replace").strip()
274 finally:
275 server.close()
276 try:
277 os.unlink(sock_path)
278 except FileNotFoundError:
279 pass
280 with self.lock:
281 self.current = None
282
283 if error:
284 self.channel.send(make_error(msg_id, 1, error))
285 return
286
287 redirect_url = restore_state(redirect_url, state.freerdp_id, state.had_original_state)
288
289 parsed = urllib.parse.urlparse(redirect_url)
290 qs = urllib.parse.parse_qs(parsed.query)
291 if "error" in qs:
292 message = qs["error"][0]
293 if "error_subcode" in qs:
294 message += ": " + qs["error_subcode"][0]
295 self.channel.send(make_error(msg_id, 1, message))
296 return
297
298 self.channel.send(make_result(msg_id, {"status": "ok", "redirect_url": redirect_url}))
299
300 def cancel(self) -> None:
301 with self.lock:
302 if self.current:
303 self.current.cancelled.set()
304
305 def shutdown(self, msg_id) -> None:
306 self.channel.send(make_result(msg_id, None))
307
308 def prepare_exit(self) -> None:
309 with self.lock:
310 self.shutting_down = True
311 if self.current:
312 self.current.cancelled.set()
313
314
315def reader_loop(channel: JsonRpcChannel, session: Session) -> None:
316 while True:
317 msg = channel.read_message()
318 if msg is None:
319 break
320 if not msg:
321 continue
322
323 method = msg.get("method")
324 msg_id = msg.get("id")
325
326 if method == "hello":
327 channel.send(make_result(
328 msg_id, {"protocol_version": 1, "helper": "freerdp-xdgopen-aad-helper/1.0"}))
329 elif method == "navigate":
330 threading.Thread(target=session.navigate, args=(msg_id, msg.get("params") or {}),
331 daemon=True).start()
332 elif method == "cancel":
333 session.cancel()
334 elif method == "shutdown":
335 session.shutdown(msg_id)
336 elif method == "exit":
337 session.prepare_exit()
338 break
339 # any other method: silently ignored, per the protocol's implementer notes
340
341
342def main() -> int:
343 cmd_in_arg = None
344 cmd_out_arg = None
345 for arg in sys.argv[1:]:
346 if arg.startswith("--cmdInFd="):
347 cmd_in_arg = arg[len("--cmdInFd="):]
348 elif arg.startswith("--cmdOutFd="):
349 cmd_out_arg = arg[len("--cmdOutFd="):]
350
351 if not cmd_in_arg or not cmd_out_arg:
352 print(f"usage: {sys.argv[0]} --cmdInFd=<handle> --cmdOutFd=<handle>", file=sys.stderr)
353 return 1
354
355 try:
356 fd_in = import_handle(cmd_in_arg)
357 fd_out = import_handle(cmd_out_arg)
358 except ValueError as exc:
359 print(f"[xdgopen-aad-helper] {exc}", file=sys.stderr)
360 return 1
361
362 ensure_handler_installed()
363
364 channel = JsonRpcChannel(fd_in, fd_out)
365 session = Session(channel)
366 reader_loop(channel, session)
367 return 0
368
369
370if __name__ == "__main__":
371 raise SystemExit(main())