diff --git a/src/azure-cli-core/azure/cli/core/auth/identity.py b/src/azure-cli-core/azure/cli/core/auth/identity.py index 91629e89441..fcce8808b58 100644 --- a/src/azure-cli-core/azure/cli/core/auth/identity.py +++ b/src/azure-cli-core/azure/cli/core/auth/identity.py @@ -9,6 +9,7 @@ import sys from azure.cli.core._environment import get_config_dir +from azure.cli.core.util import wsl_browser_open from knack.log import get_logger from knack.util import CLIError from msal import PublicClientApplication, ConfidentialClientApplication @@ -163,12 +164,13 @@ def _prompt_launching_ui(ui=None, **_): # For AAD, use port 0 to let the system choose arbitrary unused ephemeral port to avoid port collision # on port 8400 from the old design. However, ADFS only allows port 8400. - result = self._msal_app.acquire_token_interactive( - scopes, prompt='select_account', port=8400 if self._is_adfs else None, - success_template=success_template, error_template=error_template, - parent_window_handle=self._msal_app.CONSOLE_WINDOW_HANDLE, on_before_launching_ui=_prompt_launching_ui, - enable_msa_passthrough=True, - claims_challenge=claims_challenge) + with wsl_browser_open(): + result = self._msal_app.acquire_token_interactive( + scopes, prompt='select_account', port=8400 if self._is_adfs else None, + success_template=success_template, error_template=error_template, + parent_window_handle=self._msal_app.CONSOLE_WINDOW_HANDLE, on_before_launching_ui=_prompt_launching_ui, + enable_msa_passthrough=True, + claims_challenge=claims_challenge) return check_result(result) def login_with_device_code(self, scopes, claims_challenge=None): diff --git a/src/azure-cli-core/azure/cli/core/auth/tests/test_identity.py b/src/azure-cli-core/azure/cli/core/auth/tests/test_identity.py index 993039faca3..11db78bb606 100644 --- a/src/azure-cli-core/azure/cli/core/auth/tests/test_identity.py +++ b/src/azure-cli-core/azure/cli/core/auth/tests/test_identity.py @@ -45,6 +45,24 @@ class TestIdentity(unittest.TestCase): + @mock.patch("azure.cli.core.auth.identity.check_result", return_value={"username": "user1"}) + @mock.patch("azure.cli.core.auth.util.read_response_templates", return_value=("success", "error")) + @mock.patch("azure.cli.core.auth.identity.wsl_browser_open") + def test_login_with_auth_code_uses_wsl_browser_open(self, wsl_browser_open_mock, _, check_result_mock): + identity = Identity.__new__(Identity) + identity._is_adfs = False + msal_app_mock = mock.MagicMock() + msal_app_mock.CONSOLE_WINDOW_HANDLE = "console" + msal_app_mock.acquire_token_interactive.return_value = {"access_token": "token"} + + with mock.patch.object(Identity, "_msal_app", new_callable=mock.PropertyMock, return_value=msal_app_mock): + result = identity.login_with_auth_code(["scope"]) + + self.assertEqual(result, {"username": "user1"}) + wsl_browser_open_mock.assert_called_once() + msal_app_mock.acquire_token_interactive.assert_called_once() + check_result_mock.assert_called_once_with({"access_token": "token"}) + @mock.patch("azure.cli.core.auth.identity.ServicePrincipalStore.save_entry") @mock.patch("msal.application.ConfidentialClientApplication.acquire_token_for_client") @mock.patch("msal.application.ConfidentialClientApplication.__init__", return_value=None) diff --git a/src/azure-cli-core/azure/cli/core/tests/test_util.py b/src/azure-cli-core/azure/cli/core/tests/test_util.py index 47bce9832fa..e52aed1f762 100644 --- a/src/azure-cli-core/azure/cli/core/tests/test_util.py +++ b/src/azure-cli-core/azure/cli/core/tests/test_util.py @@ -17,7 +17,8 @@ (get_file_json, truncate_text, shell_safe_json_parse, b64_to_hex, hash_string, random_string, open_page_in_browser, can_launch_browser, handle_exception, ConfiguredDefaultSetter, send_raw_request, should_disable_connection_verify, parse_proxy_resource_id, get_az_user_agent, get_az_rest_user_agent, - _get_parent_proc_name, is_wsl, run_cmd, run_az_cmd, roughly_parse_command, is_same_origin) + _get_parent_proc_name, is_wsl, run_cmd, run_az_cmd, roughly_parse_command, is_same_origin, + _is_wsl_interop_enabled, _open_url_in_wsl_browser, wsl_browser_open) from azure.cli.core.mock import DummyCli @@ -179,8 +180,9 @@ def test_open_page_in_browser(self, subprocess_open_mock, webbrowser_open_mock): @mock.patch('shutil.which', autospec=True) @mock.patch('azure.cli.core.util._get_platform_info', autospec=True) + @mock.patch('azure.cli.core.util._is_wsl_interop_enabled', autospec=True, return_value=True) @mock.patch('webbrowser.get', autospec=True) - def test_can_launch_browser(self, webbrowser_get_mock, get_platform_mock, which_mock): + def test_can_launch_browser(self, webbrowser_get_mock, wsl_interop_mock, get_platform_mock, which_mock): import webbrowser # Windows is always fine @@ -207,6 +209,7 @@ def test_can_launch_browser(self, webbrowser_get_mock, get_platform_mock, which_ get_platform_mock.return_value = ('linux', '5.10.16.3-microsoft-standard-WSL2') browser_mock = mock.MagicMock() browser_mock.name = 'www-browser' + webbrowser_get_mock.side_effect = None webbrowser_get_mock.return_value = browser_mock assert can_launch_browser() @@ -222,6 +225,51 @@ def test_can_launch_browser(self, webbrowser_get_mock, get_platform_mock, which_ webbrowser_get_mock.side_effect = webbrowser.Error which_mock.return_value = False assert not can_launch_browser() + self.assertTrue(wsl_interop_mock.called) + + @mock.patch('azure.cli.core.util._get_platform_info', autospec=True, + return_value=('linux', '5.10.16.3-microsoft-standard-WSL2')) + @mock.patch('os.listdir', autospec=True, return_value=['status', 'WSLInterop-late']) + def test_is_wsl_interop_enabled_with_late_entry(self, listdir_mock, _): + m = mock.mock_open(read_data='enabled\ninterpreter /init\n') + with mock.patch('builtins.open', m): + assert _is_wsl_interop_enabled() + + listdir_mock.assert_called_once_with('/proc/sys/fs/binfmt_misc') + m.assert_called_once_with('/proc/sys/fs/binfmt_misc/WSLInterop-late', 'r') + + @mock.patch('azure.cli.core.util._open_url_in_wsl_browser', autospec=True, return_value=True) + @mock.patch('azure.cli.core.util._is_wsl_interop_enabled', autospec=True, return_value=True) + @mock.patch('azure.cli.core.util._get_platform_info', autospec=True, + return_value=('linux', '5.10.16.3-microsoft-standard-WSL2')) + def test_wsl_browser_open_uses_wsl_browser(self, _, wsl_interop_mock, open_url_mock): + import webbrowser + + original_open = webbrowser.open + with wsl_browser_open(): + assert webbrowser.open('https://login.example.com') + + open_url_mock.assert_called_once_with('https://login.example.com') + wsl_interop_mock.assert_called() + self.assertEqual(webbrowser.open, original_open) + + @mock.patch('subprocess.call', autospec=True) + @mock.patch('azure.cli.core.util._is_wsl_interop_enabled', autospec=True, return_value=True) + def test_open_url_in_wsl_browser_rejects_non_http_url(self, _, subprocess_call_mock): + assert not _open_url_in_wsl_browser('/C:/Windows/System32/calc.exe') + assert not _open_url_in_wsl_browser('https:') + subprocess_call_mock.assert_not_called() + + @mock.patch('subprocess.call', autospec=True, return_value=0) + @mock.patch('azure.cli.core.util._is_wsl_interop_enabled', autospec=True, return_value=True) + def test_open_url_in_wsl_browser_passes_url_by_env(self, _, subprocess_call_mock): + url = 'https://login.example.com/path?x=1&y=2' + + assert _open_url_in_wsl_browser(url) + + cmd = subprocess_call_mock.call_args.args[0] + self.assertNotIn(url, cmd) + self.assertEqual(subprocess_call_mock.call_args.kwargs['env']['AZURE_CLI_WSL_BROWSER_URL'], url) def test_configured_default_setter(self): config = mock.MagicMock() diff --git a/src/azure-cli-core/azure/cli/core/util.py b/src/azure-cli-core/azure/cli/core/util.py index 67c7650f5fa..a820206f05c 100644 --- a/src/azure-cli-core/azure/cli/core/util.py +++ b/src/azure-cli-core/azure/cli/core/util.py @@ -10,6 +10,7 @@ import platform import re import sys +import threading from knack.log import get_logger from knack.util import CLIError, to_snake_case, to_camel_case @@ -18,6 +19,7 @@ CLI_PACKAGE_NAME = 'azure-cli' COMPONENT_PREFIX = 'azure-cli-' +_WSL_BROWSER_OPEN_LOCK = threading.RLock() SSLERROR_TEMPLATE = ('Certificate verification failed. This typically happens when using Azure CLI behind a proxy ' 'that intercepts traffic with a self-signed certificate. ' @@ -799,14 +801,8 @@ def open_page_in_browser(url): platform_name, _ = _get_platform_info() if is_wsl(): # windows 10 linux subsystem - try: - # https://learn.microsoft.com/en-us/powershell/module/microsoft.powershell.core/about/about_powershell_exe - # Ampersand (&) should be quoted - safe_url = url.replace("'", "''") - return subprocess.Popen( - ['powershell.exe', '-NoProfile', '-Command', f"Start-Process '{safe_url}'"]).wait() - except OSError: # WSL might be too old # FileNotFoundError introduced in Python 3 - pass + if _open_url_in_wsl_browser(url): + return True elif platform_name == 'darwin': # handle 2 things: # a. On OSX sierra, 'python -m webbrowser -t ' emits out "execution error: doesn't @@ -833,6 +829,104 @@ def is_wsl(): return platform_name == 'linux' and 'microsoft' in release +def _is_wsl_interop_enabled(): + if not is_wsl(): + return False + + try: + binfmt_entries = os.listdir('/proc/sys/fs/binfmt_misc') + except OSError: + return bool(os.environ.get('WSL_INTEROP')) + + for entry in binfmt_entries: + if not entry.startswith('WSLInterop'): + continue + try: + with open(os.path.join('/proc/sys/fs/binfmt_misc', entry), 'r') as f: + if f.readline().strip() == 'enabled': + return True + except OSError: + continue + + return bool(os.environ.get('WSL_INTEROP')) + + +def _get_wsl_browser_commands(): + return [ + (['/mnt/c/Windows/System32/WindowsPowerShell/v1.0/powershell.exe', + '-NoProfile', '-Command', 'Start-Process $env:AZURE_CLI_WSL_BROWSER_URL'], (0,)), + (['powershell.exe', '-NoProfile', '-Command', 'Start-Process $env:AZURE_CLI_WSL_BROWSER_URL'], (0,)) + ] + + +def _is_safe_browser_url(url): + from urllib.parse import urlparse + + if not isinstance(url, str): + return False + + parsed_url = urlparse(url) + return (parsed_url.scheme in ('http', 'https') and bool(parsed_url.netloc) and + not any(ord(c) < 32 or ord(c) == 127 for c in url)) + + +def _open_url_in_wsl_browser(url): + if not _is_safe_browser_url(url) or not _is_wsl_interop_enabled(): + return False + + import subprocess + env = os.environ.copy() + env['AZURE_CLI_WSL_BROWSER_URL'] = url + for cmd, success_codes in _get_wsl_browser_commands(): + try: + exit_code = subprocess.call(cmd, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, env=env) + if exit_code in success_codes: + # Start-Process returns after handing off the URL to Windows. + return True + except OSError: + continue + return False + + +class _WslBrowserOpen: + def __init__(self): + self._original_open = None + self._patch_applied = False + self._lock_acquired = False + + def __enter__(self): + self._original_open = None + self._patch_applied = False + self._lock_acquired = False + if not _is_wsl_interop_enabled(): + return self + + _WSL_BROWSER_OPEN_LOCK.acquire() + self._lock_acquired = True + + import webbrowser + self._original_open = webbrowser.open + + def _open(url, *args, **kwargs): + with _WSL_BROWSER_OPEN_LOCK: + return _open_url_in_wsl_browser(url) or self._original_open(url, *args, **kwargs) + + webbrowser.open = _open + self._patch_applied = True + return self + + def __exit__(self, *args): + if self._patch_applied: + import webbrowser + webbrowser.open = self._original_open + if self._lock_acquired: + _WSL_BROWSER_OPEN_LOCK.release() + + +def wsl_browser_open(): + return _WslBrowserOpen() + + def is_windows(): platform_name, _ = _get_platform_info() return platform_name == 'windows' @@ -863,7 +957,9 @@ def can_launch_browser(): # Docker container running on WSL 2 also shows WSL, but it can't launch a browser. # If powershell.exe is on PATH, it can be called to launch a browser. import shutil - if shutil.which("powershell.exe"): + if _is_wsl_interop_enabled() and ( + shutil.which("powershell.exe") or + os.path.exists('/mnt/c/Windows/System32/WindowsPowerShell/v1.0/powershell.exe')): return True return False