Skip to content

Commit a2176eb

Browse files
authored
Better error from InteractiveBrowserCredential for invalid redirect_uri (Azure#15814)
1 parent dc95dbf commit a2176eb

File tree

3 files changed

+36
-19
lines changed

3 files changed

+36
-19
lines changed

sdk/identity/azure-identity/azure/identity/_credentials/browser.py

Lines changed: 16 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,8 @@
66
import uuid
77
import webbrowser
88

9+
from six.moves.urllib_parse import urlparse
10+
911
from azure.core.exceptions import ClientAuthenticationError
1012

1113
from .. import CredentialUnavailableError
@@ -35,7 +37,7 @@ class InteractiveBrowserCredential(InteractiveCredential):
3537
:keyword str tenant_id: an Azure Active Directory tenant ID. Defaults to the 'organizations' tenant, which can
3638
authenticate work or school accounts.
3739
:keyword str client_id: Client ID of the Azure Active Directory application users will sign in to. If
38-
unspecified, the Azure CLI's ID will be used.
40+
unspecified, users will authenticate to an Azure development application.
3941
:keyword str redirect_uri: a redirect URI for the application identified by `client_id` as configured in Azure
4042
Active Directory, for example "http://localhost:8400". This is only required when passing a value for
4143
`client_id`, and must match a redirect URI in the application's registration. The credential must be able to
@@ -48,11 +50,19 @@ class InteractiveBrowserCredential(InteractiveCredential):
4850
:keyword bool allow_unencrypted_cache: if True, the credential will fall back to a plaintext cache on platforms
4951
where encryption is unavailable. Default to False. Has no effect when `enable_persistent_cache` is False.
5052
:keyword int timeout: seconds to wait for the user to complete authentication. Defaults to 300 (5 minutes).
53+
:raises ValueError: invalid `redirect_uri`
5154
"""
5255

5356
def __init__(self, **kwargs):
5457
# type: (**Any) -> None
55-
self._redirect_uri = kwargs.pop("redirect_uri", None)
58+
redirect_uri = kwargs.pop("redirect_uri", None)
59+
if redirect_uri:
60+
self._parsed_url = urlparse(redirect_uri)
61+
if not (self._parsed_url.hostname and self._parsed_url.port):
62+
raise ValueError('"redirect_uri" must be a URL with port number, for example "http://localhost:8400"')
63+
else:
64+
self._parsed_url = None
65+
5666
self._timeout = kwargs.pop("timeout", 300)
5767
self._server_class = kwargs.pop("_server_class", AuthCodeRedirectServer)
5868
client_id = kwargs.pop("client_id", DEVELOPER_SIGN_ON_CLIENT_ID)
@@ -64,17 +74,17 @@ def _request_token(self, *scopes, **kwargs):
6474

6575
# start an HTTP server to receive the redirect
6676
server = None
67-
redirect_uri = self._redirect_uri
68-
if redirect_uri:
77+
if self._parsed_url:
6978
try:
70-
server = self._server_class(redirect_uri, timeout=self._timeout)
79+
redirect_uri = "http://{}:{}".format(self._parsed_url.hostname, self._parsed_url.port)
80+
server = self._server_class(self._parsed_url.hostname, self._parsed_url.port, timeout=self._timeout)
7181
except socket.error:
7282
raise CredentialUnavailableError(message="Couldn't start an HTTP server on " + redirect_uri)
7383
else:
7484
for port in range(8400, 9000):
7585
try:
86+
server = self._server_class("localhost", port, timeout=self._timeout)
7687
redirect_uri = "http://localhost:{}".format(port)
77-
server = self._server_class(redirect_uri, timeout=self._timeout)
7888
break
7989
except socket.error:
8090
continue # keep looking for an open port

sdk/identity/azure-identity/azure/identity/_internal/auth_code_redirect_handler.py

Lines changed: 4 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
# ------------------------------------
55
from typing import TYPE_CHECKING
66

7-
from six.moves.urllib_parse import parse_qs, urlparse
7+
from six.moves.urllib_parse import parse_qs
88

99
try:
1010
from http.server import HTTPServer, BaseHTTPRequestHandler
@@ -45,10 +45,9 @@ class AuthCodeRedirectServer(HTTPServer):
4545

4646
query_params = {} # type: Mapping[str, Any]
4747

48-
def __init__(self, uri, timeout):
49-
# type: (str, int) -> None
50-
parsed = urlparse(uri)
51-
HTTPServer.__init__(self, (parsed.hostname, parsed.port), AuthCodeRedirectHandler)
48+
def __init__(self, hostname, port, timeout):
49+
# type: (str, int, int) -> None
50+
HTTPServer.__init__(self, (hostname, port), AuthCodeRedirectHandler)
5251
self.timeout = timeout
5352

5453
def wait_for_redirect(self):

sdk/identity/azure-identity/tests/test_browser_credential.py

Lines changed: 16 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -238,7 +238,8 @@ def test_interactive_credential(mock_open, redirect_url):
238238
assert server_class.call_count == 1
239239

240240
if redirect_url:
241-
server_class.assert_called_once_with(redirect_url, timeout=ANY)
241+
parsed = urllib_parse.urlparse(redirect_url)
242+
server_class.assert_called_once_with(parsed.hostname, parsed.port, timeout=ANY)
242243

243244
# token should be cached, get_token shouldn't prompt again
244245
token = credential.get_token("scope")
@@ -287,11 +288,11 @@ def handle_request(self):
287288
def test_redirect_server():
288289
# binding a random port prevents races when running the test in parallel
289290
server = None
291+
hostname = "127.0.0.1"
290292
for _ in range(4):
291293
try:
292294
port = random.randint(1024, 65535)
293-
url = "http://127.0.0.1:{}".format(port)
294-
server = AuthCodeRedirectServer(url, timeout=10)
295+
server = AuthCodeRedirectServer(hostname, port, timeout=10)
295296
break
296297
except socket.error:
297298
continue # keep looking for an open port
@@ -307,7 +308,8 @@ def test_redirect_server():
307308
thread.start()
308309

309310
# send a request, verify the server exposes the query
310-
response = urllib.request.urlopen(url + "?{}={}".format(expected_param, expected_value)) # nosec
311+
url = "http://{}:{}".format(hostname, port) + "?{}={}".format(expected_param, expected_value)
312+
response = urllib.request.urlopen(url) # nosec
311313

312314
assert response.code == 200
313315
assert server.query_params[expected_param] == [expected_value]
@@ -323,6 +325,14 @@ def test_no_browser():
323325
credential.get_token("scope")
324326

325327

328+
@pytest.mark.parametrize("redirect_uri", ("http://localhost", "host", "host:42"))
329+
def test_invalid_redirect_uri(redirect_uri):
330+
"""The credential should raise ValueError when redirect_uri is invalid or doesn't include a port"""
331+
332+
with pytest.raises(ValueError):
333+
InteractiveBrowserCredential(redirect_uri=redirect_uri)
334+
335+
326336
def test_cannot_bind_port():
327337
"""get_token should raise CredentialUnavailableError when the redirect listener can't bind a port"""
328338

@@ -334,15 +344,13 @@ def test_cannot_bind_port():
334344
def test_cannot_bind_redirect_uri():
335345
"""When a user specifies a redirect URI, the credential shouldn't attempt to bind another"""
336346

337-
expected_uri = "http://localhost:42"
338-
339347
server = Mock(side_effect=socket.error)
340-
credential = InteractiveBrowserCredential(redirect_uri=expected_uri, _server_class=server)
348+
credential = InteractiveBrowserCredential(redirect_uri="http://localhost:42", _server_class=server)
341349

342350
with pytest.raises(CredentialUnavailableError):
343351
credential.get_token("scope")
344352

345-
server.assert_called_once_with(expected_uri, timeout=ANY)
353+
server.assert_called_once_with("localhost", 42, timeout=ANY)
346354

347355

348356
def _validate_auth_request_url(url):

0 commit comments

Comments
 (0)