2""" XDG "native browser" AAD auth helper.
17DEFAULT_TIMEOUT_MS = 180000
18BROKER_SCHEME =
"ms-appx-web://"
19DESKTOP_FILE_NAME =
"freerdp-aad-handler.desktop"
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):
32 return shutil.which(
"freerdp-aad-handler")
35def build_desktop_entry(handler_path: str) -> bytes:
39 "Name=FreeRDP AAD redirect handler\n"
40 f
"Exec={handler_path} %u\n"
41 "MimeType=x-scheme-handler/ms-appx-web;\n"
43 "StartupNotify=false\n"
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()
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)
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)
70 os.makedirs(apps_dir, exist_ok=
True)
72 if os.path.isfile(dst):
73 with open(dst,
"rb")
as f:
75 if current != desired:
76 with open(dst,
"wb")
as f:
78 subprocess.run([
"update-desktop-database", apps_dir], check=
False,
80 except OSError
as exc:
81 print(f
"[xdg-aad-helper] warning: could not install {dst!r}: {exc}", file=sys.stderr)
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)
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)
106 """Newline-delimited JSON over the raw cmdIn/cmdOut pipe fds."""
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)
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)
122 line, _, self.
_buf = self.
_buf.partition(b
"\n")
126 return json.loads(line)
130 def send(self, message: dict) ->
None:
131 data = (json.dumps(message) +
"\n").encode()
133 self.
_out.write(data)
136def make_result(msg_id, result):
137 return {
"jsonrpc":
"2.0",
"id": msg_id,
"result": result}
140def make_error(msg_id, code, message):
141 return {
"jsonrpc":
"2.0",
"id": msg_id,
"error": {
"code": code,
"message": message}}
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)
153 for key, value
in qs:
156 rewritten.append((key, f
"{freerdp_id}.{value}"))
158 rewritten.append((key, value))
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
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 +
"."
172 for key, value
in qs:
174 restored.append((key, value))
176 if not had_original_state
and value == freerdp_id:
178 if value.startswith(prefix):
179 restored.append((key, value[len(prefix):]))
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))
189 def __init__(self, freerdp_id: str):
196 def __init__(self, channel: JsonRpcChannel):
198 self.
lock = threading.Lock()
199 self.
current: NavigateState |
None =
None
202 def navigate(self, msg_id, params: dict) ->
None:
205 self.
channel.send(make_error(msg_id, 1,
"shutting_down"))
208 self.
channel.send(make_error(msg_id, 1,
"navigate_already_in_progress"))
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
218 user_agent = params.get(
"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)
224 if not redirect_uri.lower().startswith(BROKER_SCHEME):
227 self.
channel.send(make_error(msg_id, 1,
"unsupported_redirect_scheme"))
230 effective_url, state.had_original_state = rewrite_state(url, state.freerdp_id)
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")
236 except FileNotFoundError:
239 server = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
243 server.bind(sock_path)
244 os.chmod(sock_path, 0o600)
246 server.settimeout(0.5)
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])
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"
259 error =
"shutting_down"
261 if time.monotonic() >= deadline:
265 conn, _ = server.accept()
266 except socket.timeout:
273 chunk = conn.recv(4096)
277 except socket.timeout:
279 redirect_url = data.decode(errors=
"replace").strip()
284 except FileNotFoundError:
290 self.
channel.send(make_error(msg_id, 1, error))
293 redirect_url = restore_state(redirect_url, state.freerdp_id, state.had_original_state)
295 parsed = urllib.parse.urlparse(redirect_url)
296 qs = urllib.parse.parse_qs(parsed.query)
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))
304 self.
channel.send(make_result(msg_id, {
"status":
"ok",
"redirect_url": redirect_url}))
306 def cancel(self) -> None:
311 def shutdown(self, msg_id) -> None:
312 self.
channel.send(make_result(msg_id,
None))
314 def prepare_exit(self) -> None:
321def reader_loop(channel: JsonRpcChannel, session: Session) ->
None:
323 msg = channel.read_message()
329 method = msg.get(
"method")
330 msg_id = msg.get(
"id")
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 {}),
338 elif method ==
"cancel":
340 elif method ==
"shutdown":
341 session.shutdown(msg_id)
342 elif method ==
"exit":
343 session.prepare_exit()
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="):]
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)
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)
368 ensure_handler_installed()
372 reader_loop(channel, session)
376if __name__ ==
"__main__":
377 raise SystemExit(main())