Repository: snowflakedb/snowflake-connector-python

Issue:
SNOW-157438: Feature request: print `sso_url` when unable to open browser when using federated auth
Hi,

When working with tools like Google Colab or other Jupyter-based tools, it often happens that data scientists or engineers are connected to a more powerful instance (generally a VM without any browser) to work with a lot of data. If a company is using federated authentication (such as G Suite) in order to allow their employees to access data, they currently cannot connect to a Snowflake database with such a setup because calling `connect` with `authenticator='externalbrowser'` will fail with `ER_UNABLE_TO_OPEN_BROWSER` error code. A very simple solution that would help this use case (and which is used by other tools, such as https://github.com/googlecolab/colabtools) is to simply print out the `sso_url` when we are unable to open a browser. The user can then click on the link, open it using their own local browser, and authenticate from there.

I tried it myself, by just replacing the lines in the if-block (https://github.com/snowflakedb/snowflake-connector-python/blob/d83d7fbeaf56d42aa433079dc9622737a061436a/auth_webbrowser.py#L108) by a simple `print(sso_url)`, and it worked fine!

It probably also works for other IdP than Google, but I only tried with Google given that this is our setup.

I was wondering what you thought about that, and if you think it would be useful to also implement it for other languages. I find it especially useful for Python as this is a common use case for data scientists/engineers, which are most of the time using Python.

Thanks in advance for considering this simple feature request that would us quite a bit!

Discussion:
feluelle: Nice @iserko I was about to open a PR to fix this too! Thank you very much 😁 This looks great! 🚀 

cc @pecigonzalo
sfc-gh-mkeller: This works for your use-case. However; I'm not sure that this would work for every SSO user.
I know that in the URL we try to open we embed a port number where we expect the token to be posted to and then we open an HTTP server.
I think doing something like that would be a better user experience than pasting back the url that the SSO redirects you to.
feluelle: Good point! This was my first thought too, but I also like this approach. We can additionally make the port configurable through an env var. WDYT? If yes, how should we name it? Do you have a convention for those?
oliverholworthy: One idea that might improve usability could be instead of redirecting to `localhost` and seeing an error message (e.g. "This site can’t be reached").

`http://localhost:49251/?token=rVdZl6LIEn73V3jsx-qSVURPW3...`

![image](https://user-images.githubusercontent.com/1216955/143107325-dcd80aef-2259-496a-bd0d-0af3ab105e0c.png)


We could do something like this Google Auth mechanism:

![image](https://user-images.githubusercontent.com/1216955/143101696-b7e26b1c-f7d7-45cb-9b1d-e126fcec77a1.png)

where the redirect is a page dedicated to copying the token, which can then be pasted in the token prompt box?

`https://accounts.google.com/o/oauth2/approval/v2/approvalnativeapp?...`

![image](https://user-images.githubusercontent.com/1216955/143101236-4780dff6-09f5-4ef7-9042-88bcd3f02cf9.png)

Would require a dedicated page for this on the snowflake site I suppose?
oliverholworthy: This PR only changes the branch where we failed to open the browser, so perhaps this can be merged as it is?

Since It doesn't change the functionality for people when the browser does open successfully. And those that don't have access to a browser will now be able to login 😃   

Improvements to the rendering of the token (e.g. in a dedicated page on the snowflake website) vs copying the URL (this PR) could be made in the future change while maintaining backward compatibility by handling both bare tokens and URLs with a token query parameter in the parse method.
iserko: I have read the CLA Document and I hereby sign the CLA
github-actions[bot]: **CLA Assistant Lite bot:** <br/>Thank you for your submission, we really appreciate it. Like many open-source projects, we ask that you sign our [Contributor License Agreement](https://github.com/Snowflake-Labs/CLA/blob/main/README.md) before we can accept your contribution. You can sign the CLA by just posting a Pull Request Comment same as the below format.<br/>
   - - -
   ***I have read the CLA Document and I hereby sign the CLA***
   - - -
   <sub>You can retrigger this bot by commenting **recheck** in this Pull Request</sub>
iserko: I have read the CLA Document and I hereby sign the CLA
iserko: recheck

Patch:
diff --git a/src/snowflake/connector/auth_webbrowser.py b/src/snowflake/connector/auth_webbrowser.py
index 60bcfb4e..0f9ecf67 100644
--- a/src/snowflake/connector/auth_webbrowser.py
+++ b/src/snowflake/connector/auth_webbrowser.py
@@ -129,23 +129,30 @@ class AuthByWebBrowser(AuthByPlugin):
 
             logger.debug("step 2: open a browser")
             if not self._webbrowser.open_new(sso_url):
-                logger.error(
-                    "Unable to open a browser in this environment.", exc_info=True
-                )
                 print(
                     "We were unable to open a browser window for you, "
-                    f"please open the following url manually: {sso_url}"
-                )
-                self.handle_failure(
-                    {
-                        "code": ER_UNABLE_TO_OPEN_BROWSER,
-                        "message": "Unable to open a browser in this environment.",
-                    }
+                    "please open the following url manually then paste the "
+                    "URL you are redirected to into the terminal."
                 )
-                return  # required for test case
-
-            logger.debug("step 3: accept SAML token")
-            self._receive_saml_token(socket_connection)
+                print(f"URL: {sso_url}")
+                url = input("Enter the URL the SSO URL redirected you to: ")
+                self._process_get_url(url)
+                if not self._token:
+                    # Input contained no token, either URL was incorrectly pasted,
+                    # empty or just wrong
+                    self.handle_failure(
+                        {
+                            "code": ER_UNABLE_TO_OPEN_BROWSER,
+                            "message": (
+                                "Unable to open a browser in this environment and "
+                                "SSO URL contained no token"
+                            ),
+                        }
+                    )
+                    return
+            else:
+                logger.debug("step 3: accept SAML token")
+                self._receive_saml_token(socket_connection)
         finally:
             socket_connection.close()
 
@@ -265,6 +272,14 @@ You can close this window now and go back where you started from.
             ":".join(origin_line.split(":")[1:]).strip(),
         )
 
+    def _process_get_url(self, url: str) -> None:
+        parsed = parse_qs(urlparse(url).query)
+        if "token" not in parsed:
+            return
+        if not parsed["token"][0]:
+            return
+        self._token = parsed["token"][0]
+
     def _process_get(self, data):
         for line in data:
             if line.startswith("GET "):
@@ -275,7 +290,7 @@ You can close this window now and go back where you started from.
 
         self._get_user_agent(data)
         _, url, _ = target_line.split()
-        self._token = parse_qs(urlparse(url).query)["token"][0]
+        self._process_get_url(url)
         return True
 
     def _process_post(self, data):


Test patch:
diff --git a/test/unit/test_auth_webbrowser.py b/test/unit/test_auth_webbrowser.py
index 4abdbef8..8452de20 100644
--- a/test/unit/test_auth_webbrowser.py
+++ b/test/unit/test_auth_webbrowser.py
@@ -3,8 +3,8 @@
 #
 # Copyright (c) 2012-2021 Snowflake Computing Inc. All rights reserved.
 #
-
-from mock import MagicMock, Mock, PropertyMock
+import pytest
+from mock import MagicMock, Mock, PropertyMock, patch
 
 from snowflake.connector.auth_webbrowser import AuthByWebBrowser
 from snowflake.connector.constants import OCSPMode
@@ -108,9 +108,21 @@ def test_auth_webbrowser_post():
     assert body["data"]["AUTHENTICATOR"] == EXTERNAL_BROWSER_AUTHENTICATOR
 
 
-def test_auth_webbrowser_fail_webbrowser():
+@pytest.mark.parametrize(
+    "input_text,expected_error",
+    [
+        ("", True),
+        ("http://example.com/notokenurl", True),
+        ("http://example.com/sso?token=", True),
+        ("http://example.com/sso?token=MOCK_TOKEN", False),
+    ],
+)
+def test_auth_webbrowser_fail_webbrowser(
+    monkeypatch, capsys, input_text, expected_error
+):
     """Authentication by WebBrowser with failed to start web browser case."""
     rest = _init_rest(REF_SSO_URL, REF_PROOF_KEY)
+    ref_token = "MOCK_TOKEN"
 
     # mock webbrowser
     mock_webbrowser = MagicMock()
@@ -130,12 +142,30 @@ def test_auth_webbrowser_fail_webbrowser():
     auth = AuthByWebBrowser(
         rest, APPLICATION, webbrowser_pkg=mock_webbrowser, socket_pkg=mock_socket
     )
-    auth.authenticate(AUTHENTICATOR, SERVICE_NAME, ACCOUNT, USER, PASSWORD)
-    assert rest._connection.errorhandler.called  # an error
-    assert auth.assertion_content is None
-
-
-def test_auth_webbrowser_fail_webserver():
+    with patch("builtins.input", return_value=input_text):
+        auth.authenticate(AUTHENTICATOR, SERVICE_NAME, ACCOUNT, USER, PASSWORD)
+    captured = capsys.readouterr()
+    assert captured.out == (
+        "Initiating login request with your identity provider. A browser window "
+        "should have opened for you to complete the login. If you can't see it, "
+        "check existing browser windows, or your OS settings. Press CTRL+C to "
+        "abort and try again...\nWe were unable to open a browser window for "
+        "you, please open the following url manually then paste the URL you "
+        f"are redirected to into the terminal.\nURL: {REF_SSO_URL}\n"
+    )
+    if expected_error:
+        assert rest._connection.errorhandler.called  # an error
+        assert auth.assertion_content is None
+    else:
+        assert not rest._connection.errorhandler.called  # no error
+        body = {"data": {}}
+        auth.update_body(body)
+        assert body["data"]["TOKEN"] == ref_token
+        assert body["data"]["PROOF_KEY"] == REF_PROOF_KEY
+        assert body["data"]["AUTHENTICATOR"] == EXTERNAL_BROWSER_AUTHENTICATOR
+
+
+def test_auth_webbrowser_fail_webserver(capsys):
     """Authentication by WebBrowser with failed to start web browser case."""
     rest = _init_rest(REF_SSO_URL, REF_PROOF_KEY)
 
@@ -159,6 +189,13 @@ def test_auth_webbrowser_fail_webserver():
         rest, APPLICATION, webbrowser_pkg=mock_webbrowser, socket_pkg=mock_socket
     )
     auth.authenticate(AUTHENTICATOR, SERVICE_NAME, ACCOUNT, USER, PASSWORD)
+    captured = capsys.readouterr()
+    assert captured.out == (
+        "Initiating login request with your identity provider. A browser window "
+        "should have opened for you to complete the login. If you can't see it, "
+        "check existing browser windows, or your OS settings. Press CTRL+C to "
+        "abort and try again...\n"
+    )
     assert rest._connection.errorhandler.called  # an error
     assert auth.assertion_content is None