Repository: pydata/pydata-google-auth

Issue:
ENH: Try a range of ports if `use_local_webserver` is `True`
It's possible that the default port of `8080` in https://google-auth-oauthlib.readthedocs.io/en/latest/reference/google_auth_oauthlib.flow.html#google_auth_oauthlib.flow.InstalledAppFlow.run_local_server is occupied.

```
$ python -m http.server --directory docs
$ pytest tests/system
```

If this happens, the error happens a bit late, actually. It still pops up the browser window, but then authentication fails with

```
E               pydata_google_auth.exceptions.PyDataCredentialsError: Unable to get valid credentials: (missing_code) Missing code parameter in response.
```

Discussion:
tswast: System tests pass locally (for the first time in a while, actually. I think it helps to have the port discovery. I do notice ports 8081 and 8082 often getting used, perhaps because the server from other tests takes some time to close)

```
$ nox -s system
nox > Running session system-2.7
nox > Creating virtual environment (virtualenv) using python2.7 in .nox/system-2-7
nox > pip install mock pyfakefs pytest pytest-cov
nox > pip install -e .
nox > pytest ./tests/system --quiet --cov=pydata_google_auth --cov=tests.system --cov-report xml:/tmp/pytest-cov.xml
.....                                                                                                                                      [100%]Coverage.py warning: Couldn't parse Python file '/Users/swast/src/pydata-google-auth/pydata_google_auth/__main__.py' (couldnt-parse)


---------- coverage: platform darwin, python 2.7.16-final-0 ----------
Coverage XML written to file /tmp/pytest-cov.xml

5 passed in 65.17 seconds
nox > Session system-2.7 was successful.
nox > Running session system-3.5
nox > Creating virtual environment (virtualenv) using python3.5 in .nox/system-3-5
nox > pip install mock pyfakefs pytest pytest-cov
nox > pip install -e .
nox > pytest ./tests/system --quiet --cov=pydata_google_auth --cov=tests.system --cov-report xml:/tmp/pytest-cov.xml
.....                                                                                                                                      [100%]

---------- coverage: platform darwin, python 3.5.4-final-0 -----------
Coverage XML written to file /tmp/pytest-cov.xml

5 passed in 18.04s
nox > Session system-3.5 was successful.
nox > Running session system-3.6
nox > Creating virtual environment (virtualenv) using python3.6 in .nox/system-3-6
nox > pip install mock pyfakefs pytest pytest-cov
nox > pip install -e .
nox > pytest ./tests/system --quiet --cov=pydata_google_auth --cov=tests.system --cov-report xml:/tmp/pytest-cov.xml
.....                                                                                                                                      [100%]

---------- coverage: platform darwin, python 3.6.4-final-0 -----------
Coverage XML written to file /tmp/pytest-cov.xml

5 passed in 11.77s
nox > Session system-3.6 was successful.
nox > Running session system-3.7
nox > Creating virtual environment (virtualenv) using python3.7 in .nox/system-3-7
nox > pip install mock pyfakefs pytest pytest-cov
nox > pip install -e .
nox > pytest ./tests/system --quiet --cov=pydata_google_auth --cov=tests.system --cov-report xml:/tmp/pytest-cov.xml
.....                                                                                                                                      [100%]

---------- coverage: platform darwin, python 3.7.3-final-0 -----------
Coverage XML written to file /tmp/pytest-cov.xml

5 passed in 10.11s
nox > Session system-3.7 was successful.
nox > Running session system-3.8
nox > Creating virtual environment (virtualenv) using python3.8 in .nox/system-3-8
nox > pip install mock pyfakefs pytest pytest-cov
nox > pip install -e .
nox > pytest ./tests/system --quiet --cov=pydata_google_auth --cov=tests.system --cov-report xml:/tmp/pytest-cov.xml
.....                                                                                                                                      [100%]

---------- coverage: platform darwin, python 3.8.0-final-0 -----------
Coverage XML written to file /tmp/pytest-cov.xml

5 passed in 10.92s
nox > Session system-3.8 was successful.
nox > Ran multiple sessions:
nox > * system-2.7: success
nox > * system-3.5: success
nox > * system-3.6: success
nox > * system-3.7: success
nox > * system-3.8: success
```

Patch:
diff --git a/docs/source/changelog.rst b/docs/source/changelog.rst
index 822cb8c..523350b 100644
--- a/docs/source/changelog.rst
+++ b/docs/source/changelog.rst
@@ -1,6 +1,14 @@
 Changelog
 =========
 
+.. _changelog-1.1.0:
+
+1.1.0 / TBD
+-----------
+
+- Try a range of ports between 8080 and 8090 when ``use_local_webserver`` is
+  ``True``. (:issue:`35`)
+
 .. _changelog-1.0.0:
 
 1.0.0 / (2020-04-20)
diff --git a/noxfile.py b/noxfile.py
index 47ad121..4d17e1d 100644
--- a/noxfile.py
+++ b/noxfile.py
@@ -56,7 +56,7 @@ def unit(session):
 @nox.session(python=latest_python)
 def cover(session):
     session.install("coverage", "pytest-cov")
-    session.run("coverage", "report", "--show-missing", "--fail-under=40")
+    session.run("coverage", "report", "--show-missing", "--fail-under=50")
     session.run("coverage", "erase")
 
 
diff --git a/pydata_google_auth/__main__.py b/pydata_google_auth/__main__.py
index 2d93984..d77ac3b 100644
--- a/pydata_google_auth/__main__.py
+++ b/pydata_google_auth/__main__.py
@@ -31,8 +31,9 @@ LOGIN_CLIENT_SECRET_HELP = LOGIN_CLIENT_ID_HELP_TEMPLATE.format(
 )
 LOGIN_USE_LOCAL_WEBSERVER_HELP = (
     "Use a local webserver for the user authentication. This starts "
-    "a webserver on localhost, which allows the browser to pass a token "
-    "directly to the program."
+    "a webserver on localhost with a port between 8080 and 8089, "
+    "inclusive, which allows the browser to pass a token directly to the "
+    "program."
 )
 
 PRINT_TOKEN_HELP = "Load a credentials JSON file and print an access token."
diff --git a/pydata_google_auth/_webserver.py b/pydata_google_auth/_webserver.py
new file mode 100644
index 0000000..ba31972
--- /dev/null
+++ b/pydata_google_auth/_webserver.py
@@ -0,0 +1,89 @@
+"""Helpers for running a local webserver to receive authorization code."""
+
+import socket
+from contextlib import closing
+
+from pydata_google_auth import exceptions
+
+
+LOCALHOST = "localhost"
+DEFAULT_PORTS_TO_TRY = 100
+
+
+def is_port_open(port):
+    """Check if a port is open on localhost.
+
+    Based on StackOverflow answer: https://stackoverflow.com/a/43238489/101923
+
+    Parameters
+    ----------
+    port : int
+        A port to check on localhost.
+
+    Returns
+    -------
+    is_open : bool
+        True if a socket can be opened at the requested port.
+    """
+    with closing(socket.socket(socket.AF_INET, socket.SOCK_STREAM)) as sock:
+        try:
+            sock.bind((LOCALHOST, port))
+            sock.listen(1)
+        except socket.error:
+            is_open = False
+        else:
+            is_open = True
+    return is_open
+
+
+def find_open_port(start=8080, stop=None):
+    """Find an open port between ``start`` and ``stop``.
+
+    Parameters
+    ----------
+    start : Optional[int]
+        Beginning of range of ports to try. Defaults to 8080.
+    stop : Optional[int]
+        End of range of ports to try (not including exactly equals ``stop``).
+        This function tries 100 possible ports if no ``stop`` is specified.
+
+    Returns
+    -------
+    Optional[int]
+        ``None`` if no open port is found, otherwise an integer indicating an
+        open port.
+    """
+    if not stop:
+        stop = start + DEFAULT_PORTS_TO_TRY
+
+    for port in range(start, stop):
+        if is_port_open(port):
+            return port
+
+    # No open ports found.
+    return None
+
+
+def run_local_server(app_flow):
+    """Run local webserver installed app flow on some open port.
+
+    Parameters
+    ----------
+    app_flow : google_auth_oauthlib.flow.InstalledAppFlow
+        Installed application flow to fetch user credentials.
+
+    Returns
+    -------
+    google.auth.credentials.Credentials
+        User credentials from installed application flow.
+
+    Raises
+    ------
+    pydata_google_auth.exceptions.PyDataConnectionError
+        If no open port can be found in the range from 8080 to 8089,
+        inclusive.
+    """
+    port = find_open_port()
+    if not port:
+        raise exceptions.PyDataConnectionError("Could not find open port.")
+    return app_flow.run_local_server(host=LOCALHOST, port=port)
diff --git a/pydata_google_auth/auth.py b/pydata_google_auth/auth.py
index 842075c..6c9a7ba 100644
--- a/pydata_google_auth/auth.py
+++ b/pydata_google_auth/auth.py
@@ -11,6 +11,7 @@ import google.auth.transport.requests
 
 from pydata_google_auth import exceptions
 from pydata_google_auth import cache
+from pydata_google_auth import _webserver
 
 
 logger = logging.getLogger(__name__)
@@ -69,7 +70,9 @@ def default(
         Windows.
     use_local_webserver : bool, optional
         Use a local webserver for the user authentication
-        :class:`google_auth_oauthlib.flow.InstalledAppFlow`. Defaults to
+        :class:`google_auth_oauthlib.flow.InstalledAppFlow`. Binds a
+        webserver to an open port on ``localhost`` between 8080 and 8089,
+        inclusive, to receive authentication token. If not set, defaults to
         ``False``, which requests a token via the console.
     auth_local_webserver : deprecated
         Use the ``use_local_webserver`` parameter instead.
@@ -210,7 +213,9 @@ def get_user_credentials(
         Windows.
     use_local_webserver : bool, optional
         Use a local webserver for the user authentication
-        :class:`google_auth_oauthlib.flow.InstalledAppFlow`. Defaults to
+        :class:`google_auth_oauthlib.flow.InstalledAppFlow`. Binds a
+        webserver to an open port on ``localhost`` between 8080 and 8089,
+        inclusive, to receive authentication token. If not set, defaults to
         ``False``, which requests a token via the console.
     auth_local_webserver : deprecated
         Use the ``use_local_webserver`` parameter instead.
@@ -256,7 +261,7 @@ def get_user_credentials(
 
         try:
             if use_local_webserver:
-                credentials = app_flow.run_local_server()
+                credentials = _webserver.run_local_server(app_flow)
             else:
                 credentials = app_flow.run_console()
         except oauthlib.oauth2.rfc6749.errors.OAuth2Error as exc:
@@ -310,7 +315,9 @@ def save_user_credentials(
         client's identity when using Google APIs.
     use_local_webserver : bool, optional
         Use a local webserver for the user authentication
-        :class:`google_auth_oauthlib.flow.InstalledAppFlow`. Defaults to
+        :class:`google_auth_oauthlib.flow.InstalledAppFlow`. Binds a
+        webserver to an open port on ``localhost`` between 8080 and 8089,
+        inclusive, to receive authentication token. If not set, defaults to
         ``False``, which requests a token via the console.
 
     Returns
diff --git a/pydata_google_auth/exceptions.py b/pydata_google_auth/exceptions.py
index 714bd41..776ae7a 100644
--- a/pydata_google_auth/exceptions.py
+++ b/pydata_google_auth/exceptions.py
@@ -2,3 +2,9 @@ class PyDataCredentialsError(ValueError):
     """
     Raised when invalid credentials are provided, or tokens have expired.
     """
+
+
+class PyDataConnectionError(RuntimeError):
+    """
+    Raised when unable to fetch credentials due to connection error.
+    """


Test patch:
diff --git a/tests/unit/test_webserver.py b/tests/unit/test_webserver.py
new file mode 100644
index 0000000..ff02126
--- /dev/null
+++ b/tests/unit/test_webserver.py
@@ -0,0 +1,74 @@
+# -*- coding: utf-8 -*-
+
+import socket
+
+try:
+    from unittest import mock
+except ImportError:  # pragma: NO COVER
+    import mock
+
+import google_auth_oauthlib.flow
+import pytest
+
+from pydata_google_auth import exceptions
+
+
+@pytest.fixture
+def module_under_test():
+    from pydata_google_auth import _webserver
+
+    return _webserver
+
+
+def test_find_open_port_finds_start_port(monkeypatch, module_under_test):
+    monkeypatch.setattr(socket, "socket", mock.create_autospec(socket.socket))
+    port = module_under_test.find_open_port(9999)
+    assert port == 9999
+
+
+def test_find_open_port_finds_stop_port(monkeypatch, module_under_test):
+    socket_instance = mock.create_autospec(socket.socket, instance=True)
+
+    def mock_socket(family, type_):
+        return socket_instance
+
+    monkeypatch.setattr(socket, "socket", mock_socket)
+    socket_instance.listen.side_effect = [socket.error] * 99 + [None]
+    port = module_under_test.find_open_port(9000, stop=9100)
+    assert port == 9099
+
+
+def test_find_open_port_returns_none(monkeypatch, module_under_test):
+    socket_instance = mock.create_autospec(socket.socket, instance=True)
+
+    def mock_socket(family, type_):
+        return socket_instance
+
+    monkeypatch.setattr(socket, "socket", mock_socket)
+    socket_instance.listen.side_effect = socket.error
+    port = module_under_test.find_open_port(9000)
+    assert port is None
+    socket_instance.listen.assert_has_calls(mock.call(1) for _ in range(100))
+
+
+def test_run_local_server_calls_flow(monkeypatch, module_under_test):
+    mock_flow = mock.create_autospec(
+        google_auth_oauthlib.flow.InstalledAppFlow, instance=True
+    )
+    module_under_test.run_local_server(mock_flow)
+    mock_flow.run_local_server.assert_called_once_with(host="localhost", port=8080)
+
+
+def test_run_local_server_raises_connectionerror(monkeypatch, module_under_test):
+    def mock_find_open_port():
+        return None
+
+    monkeypatch.setattr(module_under_test, "find_open_port", mock_find_open_port)
+    mock_flow = mock.create_autospec(
+        google_auth_oauthlib.flow.InstalledAppFlow, instance=True
+    )
+
+    with pytest.raises(exceptions.PyDataConnectionError):
+        module_under_test.run_local_server(mock_flow)
+
+    mock_flow.run_local_server.assert_not_called()