diff --git a/sdk/identity/azure-identity/CHANGELOG.md b/sdk/identity/azure-identity/CHANGELOG.md index c38c0bed2e1e..0adb8f0f1396 100644 --- a/sdk/identity/azure-identity/CHANGELOG.md +++ b/sdk/identity/azure-identity/CHANGELOG.md @@ -11,6 +11,10 @@ when instructed to by a Retry-After header - ManagedIdentityCredential caches tokens correctly +### Added +- `InteractiveBrowserCredential` functions in more WSL environments + ([#17615](https://github.com/Azure/azure-sdk-for-python/issues/17615)) + ## 1.6.0b2 (2021-03-09) ### Breaking Changes > These changes do not impact the API of stable versions such as 1.5.0. diff --git a/sdk/identity/azure-identity/azure/identity/_credentials/browser.py b/sdk/identity/azure-identity/azure/identity/_credentials/browser.py index 5e4d76480ba9..4f2cc5efbdee 100644 --- a/sdk/identity/azure-identity/azure/identity/_credentials/browser.py +++ b/sdk/identity/azure-identity/azure/identity/_credentials/browser.py @@ -2,7 +2,9 @@ # Copyright (c) Microsoft Corporation. # Licensed under the MIT License. # ------------------------------------ +import platform import socket +import subprocess import webbrowser from six.moves.urllib_parse import urlparse @@ -101,7 +103,7 @@ def _request_token(self, *scopes, **kwargs): if "auth_uri" not in flow: raise CredentialUnavailableError("Failed to begin authentication flow") - if not webbrowser.open(flow["auth_uri"]): + if not _open_browser(flow["auth_uri"]): raise CredentialUnavailableError(message="Failed to open a browser") # block until the server times out or receives the post-authentication redirect @@ -113,3 +115,25 @@ def _request_token(self, *scopes, **kwargs): # redeem the authorization code for a token return app.acquire_token_by_auth_code_flow(flow, response, scopes=scopes, claims_challenge=claims) + + +def _open_browser(url): + opened = webbrowser.open(url) + if not opened: + uname = platform.uname() + system = uname[0].lower() + release = uname[2].lower() + if "microsoft" in release and system == "linux": + kwargs = {} + if platform.python_version() >= "3.3": + kwargs["timeout"] = 5 + + try: + exit_code = subprocess.call( + ["powershell.exe", "-NoProfile", "-Command", 'Start-Process "{}"'.format(url)], **kwargs + ) + opened = exit_code == 0 + except Exception: # pylint:disable=broad-except + # powershell.exe isn't available, or the subprocess timed out + pass + return opened diff --git a/sdk/identity/azure-identity/tests/test_browser_credential.py b/sdk/identity/azure-identity/tests/test_browser_credential.py index 5d9af1025a60..967a988e44c2 100644 --- a/sdk/identity/azure-identity/tests/test_browser_credential.py +++ b/sdk/identity/azure-identity/tests/test_browser_credential.py @@ -2,6 +2,7 @@ # Copyright (c) Microsoft Corporation. # Licensed under the MIT License. # ------------------------------------ +import platform import random import socket import threading @@ -168,10 +169,12 @@ def test_redirect_server(): def test_no_browser(): + """The credential should raise CredentialUnavailableError when it can't open a browser""" + transport = validating_transport(requests=[Request()] * 2, responses=[get_discovery_response()] * 2) credential = InteractiveBrowserCredential(client_id="client-id", _server_class=Mock(), transport=transport) - with pytest.raises(ClientAuthenticationError, match=r".*browser.*"): - with patch(WEBBROWSER_OPEN, lambda _: False): + with patch(InteractiveBrowserCredential.__module__ + "._open_browser", lambda _: False): + with pytest.raises(CredentialUnavailableError, match=r".*browser.*"): credential.get_token("scope") @@ -261,3 +264,79 @@ def test_claims_challenge(): assert msal_app.acquire_token_silent_with_error.call_count == 1 args, kwargs = msal_app.acquire_token_silent_with_error.call_args assert kwargs["claims_challenge"] == expected_claims + + +@pytest.mark.parametrize( + "uname,is_wsl", + ( + ( + ( + "Linux", + "machine", + "4.4.0-19041-Microsoft", + "#488-Microsoft Mon Sep 01 13:43:00 PST 2020", + "x86_64", + "x86_64", + ), + True, + ), + ( + ( + "Linux", + "machine", + "5.4.72-microsoft-standard-WSL2", + "#1 SMP Wed Oct 28 23:40:43 UTC 2020", + "x86_64", + "x86_64", + ), + True, + ), + ( + ( + "Linux", + "machine", + "5.3.0-51-generic", + "#44-Ubuntu SMP Wed Apr 22 21:09:44 UTC 2020", + "x86_64", + "x86_64", + ), + False, + ), + ), +) +def test_wsl_fallback(uname, is_wsl): + """the credential should invoke powershell.exe to open a browser in WSL when webbrowser.open fails""" + + auth_uri = "http://localhost" + expected_access_token = "**" + msal_acquire_token_result = dict( + build_aad_response(access_token=expected_access_token, id_token=build_id_token()), + id_token_claims=id_token_claims("issuer", "subject", "audience", upn="upn"), + ) + msal_app = Mock( + initiate_auth_code_flow=Mock(return_value={"auth_uri": auth_uri}), + acquire_token_by_auth_code_flow=Mock(return_value=msal_acquire_token_result), + ) + + transport = Mock(send=Mock(side_effect=Exception("this test mocks MSAL, so no request should be sent"))) + credential = InteractiveBrowserCredential(_server_class=Mock(), transport=transport) + + with patch(InteractiveBrowserCredential.__module__ + ".subprocess.call") as subprocess_call: + subprocess_call.return_value = 0 + with patch(InteractiveBrowserCredential.__module__ + ".platform.uname", lambda: uname): + with patch.object(InteractiveBrowserCredential, "_get_app", lambda _: msal_app): + with patch(WEBBROWSER_OPEN, lambda _: False): + try: + token = credential.get_token("scope") + except CredentialUnavailableError: + assert not is_wsl, "credential should invoke powershell.exe in WSL" + return + + assert is_wsl, "credential should raise CredentialUnavailableError when not in WSL" + assert token.token == expected_access_token + assert subprocess_call.call_count == 1 + args, kwargs = subprocess_call.call_args + assert args[0][0] == "powershell.exe" + assert auth_uri in args[0][-1] + if platform.python_version() >= "3.3": + assert "timeout" in kwargs