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 if not redirect_uri.lower().startswith(BROKER_SCHEME):
221 self.
channel.send(make_error(msg_id, 1,
"unsupported_redirect_scheme"))
224 effective_url, state.had_original_state = rewrite_state(url, state.freerdp_id)
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")
230 except FileNotFoundError:
233 server = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
237 server.bind(sock_path)
238 os.chmod(sock_path, 0o600)
240 server.settimeout(0.5)
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])
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"
253 error =
"shutting_down"
255 if time.monotonic() >= deadline:
259 conn, _ = server.accept()
260 except socket.timeout:
267 chunk = conn.recv(4096)
271 except socket.timeout:
273 redirect_url = data.decode(errors=
"replace").strip()
278 except FileNotFoundError:
284 self.
channel.send(make_error(msg_id, 1, error))
287 redirect_url = restore_state(redirect_url, state.freerdp_id, state.had_original_state)
289 parsed = urllib.parse.urlparse(redirect_url)
290 qs = urllib.parse.parse_qs(parsed.query)
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))
298 self.
channel.send(make_result(msg_id, {
"status":
"ok",
"redirect_url": redirect_url}))
300 def cancel(self) -> None:
305 def shutdown(self, msg_id) -> None:
306 self.
channel.send(make_result(msg_id,
None))
308 def prepare_exit(self) -> None:
315def reader_loop(channel: JsonRpcChannel, session: Session) ->
None:
317 msg = channel.read_message()
323 method = msg.get(
"method")
324 msg_id = msg.get(
"id")
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 {}),
332 elif method ==
"cancel":
334 elif method ==
"shutdown":
335 session.shutdown(msg_id)
336 elif method ==
"exit":
337 session.prepare_exit()
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="):]
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)
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)
362 ensure_handler_installed()
366 reader_loop(channel, session)
370if __name__ ==
"__main__":
371 raise SystemExit(main())