Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 9 additions & 6 deletions src/azure-cli-core/azure/cli/core/auth/identity.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,8 @@
from knack.util import CLIError
from msal import PublicClientApplication, ConfidentialClientApplication

from azure.cli.core.util import wsl_browser_open

from .constants import AZURE_CLI_CLIENT_ID
from .msal_credentials import UserCredential, ServicePrincipalCredential
from .persistence import load_persisted_token_cache, file_extensions, load_secret_store
Expand Down Expand Up @@ -163,12 +165,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):
Expand Down
18 changes: 18 additions & 0 deletions src/azure-cli-core/azure/cli/core/auth/tests/test_identity.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
52 changes: 50 additions & 2 deletions src/azure-cli-core/azure/cli/core/tests/test_util.py
Original file line number Diff line number Diff line change
Expand Up @@ -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


Expand Down Expand Up @@ -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
Expand All @@ -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()

Expand All @@ -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()
Expand Down
109 changes: 100 additions & 9 deletions src/azure-cli-core/azure/cli/core/util.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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. '
Expand Down Expand Up @@ -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 <url>' emits out "execution error: <url> doesn't
Expand All @@ -833,6 +829,99 @@ 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 __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'
Expand Down Expand Up @@ -863,7 +952,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
Expand Down
Loading