Repository: apache/libcloud
Issue: OAuth2 Desktop Client login is broken for base google driver

## Summary

You can no longer sign into the google driver using a OAuth2 Desktop Client, [since out-of-band authentication is deprecated and has been removed in October.
](https://developers.google.com/identity/protocols/oauth2/resources/oob-migration)


## Detailed Information

Code to reproduce (you need an OAuth2 Client Id with Desktop type).
If you already have a token you are fine until it expires, to reproduce you need to rename or delete the file `~/.google_libcloud_auth.<your client id>` 

```python
from libcloud.storage.providers import get_driver

if __name__ == '__main__':
    driver_type = get_driver("google_storage")
    driver_type(key="YOUR CLIENT ID", secret="YOUR CLIENT SECRET")
```
This will now display an URL for you do click on and sign in, where you used to receive a code to copy-paste and would then proceed to be logged in. 
Now, you just get a window that your request is invalid.

The issue is in `libcloud.common.google.GoogleInstalledAppAuthConnection.get_code` (line 423 in libcloud 3.6.1).
There, the redirect url is set to self.redirect_url, which can not be changed and is currently the default value defined in 
`libcloud.common.google.GoogleBaseAuthConnection.__init__`: `"urn:ietf:wg:oauth:2.0:oob"`, which is used for the out-of-band authentication.

The new workflow works without copy and pasting if you change the redirect_uri to `"http://127.0.0.1:8087` and then have a local webserver running to receive the redirect.

I will post a pull request to fix this as soon as I can.

## Workaround

If you can not wait for the release of the new version with this issue fixed, here is a workaround:

1. Go to `libcloud.common.google` and find the method `GoogleInstalledAppConnection.get_code`
2. Within that method, there is a parameter `"redirect_uri": self.redirect_uri,` . Replace that line with `"redirect_uri": "http://127.0.0.1:8087/",`
3. Run the following snippet
 ```python
from libcloud.storage.providers import get_driver

if __name__ == '__main__':
    driver_type = get_driver("google_storage")
    driver_type(key="YOUR CLIENT ID", secret="YOUR CLIENT SECRET")
 ```
4. It will print out an URL. Follow the instructions there. IMPORTANT: before you finish the sign in, you need to open the development console in your browser and go to the network tab
5. Finish sign in. In the network tab you will find a GET request to the domain 127.0.0.1:8087. Click on it, there should be a parameter in the url that reads code=<SOME-LETTERS-AND-NUMBERS> . You need to copy this code (without the code=)
7. On your terminal, make the following request using cURL; don't forget to  paste your code in the marked spot!
```
curl -X POST "https://oauth2.googleapis.com/token" --data-urlencode "code=<PASTE YOUR COPIED CODE HERE>" --data-urlencode "client_id=<YOUR CLIENT ID>" --data-urlencode "client_secret=<YOUR CLIENT SECRET>" --data-urlencode "redirect_uri=http://127.0.0.1:8087/" --data-urlencode "grant_type=authorization_code" > $HOME/.google_libcloud_auth.<YOUR CLIENT ID>
```
8. You should now be able to use your app again since you already have a token and a different execution path is taken.


Changed files:
libcloud/test/common/test_google.py, libcloud/common/google.py

diff --git a/libcloud/common/google.py b/libcloud/common/google.py
index fb5afe08e..b7991deb1 100644
--- a/libcloud/common/google.py
+++ b/libcloud/common/google.py
@@ -73,7 +73,9 @@ import errno
 import base64
 import logging
 import datetime
+import urllib.parse
 from typing import Optional
+from http.server import BaseHTTPRequestHandler, HTTPServer
 
 from libcloud.utils.py3 import b, httplib, urlparse, urlencode
 from libcloud.common.base import BaseDriver, JsonResponse, PollingConnection, ConnectionUserAndKey
@@ -314,13 +316,14 @@ class GoogleBaseAuthConnection(ConnectionUserAndKey):
     name = "Google Auth"
     host = "accounts.google.com"
     auth_path = "/o/oauth2/auth"
+    redirect_uri_port = 8087
 
     def __init__(
         self,
         user_id,
         key=None,
         scopes=None,
-        redirect_uri="urn:ietf:wg:oauth:2.0:oob",
+        redirect_uri="http://127.0.0.1",
         login_hint=None,
         **kwargs,
     ):
@@ -407,10 +410,11 @@ class GoogleBaseAuthConnection(ConnectionUserAndKey):
 class GoogleInstalledAppAuthConnection(GoogleBaseAuthConnection):
     """Authentication connection for "Installed Application" authentication."""
 
+    _state = "Libcloud Request"
+
     def get_code(self):
         """
-        Give the user a URL that they can visit to authenticate and obtain a
-        code.  This method will ask for that code that the user can paste in.
+        Give the user a URL that they can visit to authenticate.
 
         Mocked in libcloud.test.common.google.GoogleTestCase.
 
@@ -420,9 +424,9 @@ class GoogleInstalledAppAuthConnection(GoogleBaseAuthConnection):
         auth_params = {
             "response_type": "code",
             "client_id": self.user_id,
-            "redirect_uri": self.redirect_uri,
+            "redirect_uri": self._redirect_uri_with_port,
             "scope": self.scopes,
-            "state": "Libcloud Request",
+            "state": self._state,
         }
         if self.login_hint:
             auth_params["login_hint"] = self.login_hint
@@ -432,7 +436,7 @@ class GoogleInstalledAppAuthConnection(GoogleBaseAuthConnection):
         url = "https://{}{}?{}".format(self.host, self.auth_path, data)
         print("\\nPlease Go to the following URL and sign in:")
         print(url)
-        code = input("Enter Code: ")
+        code = self._receive_code_through_local_loopback()
         return code
 
     def get_new_token(self):
@@ -443,14 +447,13 @@ class GoogleInstalledAppAuthConnection(GoogleBaseAuthConnection):
         :return:  Dictionary containing token information
         :rtype:   ``dict``
         """
-        # Ask the user for a code
         code = self.get_code()
 
         token_request = {
             "code": code,
             "client_id": self.user_id,
             "client_secret": self.key,
-            "redirect_uri": self.redirect_uri,
+            "redirect_uri": self._redirect_uri_with_port,
             "grant_type": "authorization_code",
         }
 
@@ -480,6 +483,62 @@ class GoogleInstalledAppAuthConnection(GoogleBaseAuthConnection):
             new_token["refresh_token"] = token_info["refresh_token"]
         return new_token
 
+    @property
+    def _redirect_uri_with_port(self):
+        return self.redirect_uri + ":" + str(self.redirect_uri_port)
+
+    def _receive_code_through_local_loopback(self):
+        """
+        Start a local HTTP server that listens to a single GET request that is expected to be made
+        by the loopback in the sign-in process and stops again afterwards.
+        See https://developers.google.com/identity/protocols/oauth2/native-app#redirect-uri_loopback
+
+        :return: The access code that was extracted from the local loopback GET request
+        :rtype: ``str``
+        """
+        access_code = None
+
+        class AccessCodeReceiver(BaseHTTPRequestHandler):
+
+            # noinspection PyMethodParameters,PyPep8Naming
+            def do_GET(self_):
+                query = urlparse.urlparse(self_.path).query
+                query_components = dict(qc.split("=") for qc in query.split("&"))
+                if "state" in query_components and query_components["state"] != urllib.parse.quote(
+                    self._state
+                ):
+                    raise ValueError(
+                        "States do not match: {} != {}, can't trust authentication".format(
+                            self._state, query_components["state"]
+                        )
+                    )
+                nonlocal access_code
+                access_code = query_components["code"]
+                self_.send_response(200)
+                self_.send_header("Content-type", "text/html")
+                self_.end_headers()
+                self_.wfile.write("<html><head><title>Libcloud Sign-In</title></head>".encode())
+                self_.wfile.write("<body><p>You can now close this tab</p>".encode())
+
+        if (
+            "127.0.0.1" in self.redirect_uri
+            or "[::1]" in self.redirect_uri
+            or "localhost" in self.redirect_uri
+        ):
+            # HTTPServer does not understand localhost unless you explicitly call it so
+            server_address = "localhost", self.redirect_uri_port
+        else:
+            server_address = self.redirect_uri, self.redirect_uri_port
+
+        server = HTTPServer(server_address=server_address, RequestHandlerClass=AccessCodeReceiver)
+        # Only waits for a single request and stops afterwards
+        server.handle_request()
+        if access_code is None:
+            raise RuntimeError(
+                "Could not receive OAuth2 code: could not extract code though loopback"
+            )
+        return access_code
+
 
 class GoogleServiceAcctAuthConnection(GoogleBaseAuthConnection):
     """Authentication class for "Service Account" authentication."""
diff --git a/libcloud/test/common/test_google.py b/libcloud/test/common/test_google.py
index 082b60502..efe3fba74 100644
--- a/libcloud/test/common/test_google.py
+++ b/libcloud/test/common/test_google.py
@@ -18,9 +18,13 @@ Tests for Google Connection classes.
 import os
 import sys
 import datetime
+import threading
 import unittest
+import urllib
 from unittest import mock
 
+import requests
+
 from libcloud.test import MockHttp, LibcloudTestCase
 from libcloud.utils.py3 import httplib
 from libcloud.common.google import (
@@ -177,11 +181,6 @@ class GoogleTestCase(LibcloudTestCase):
         "libcloud.common.google.GoogleOAuth2Credential._write_token_to_file"
     )
 
-    _ia_get_code_patcher = mock.patch(
-        "libcloud.common.google.GoogleInstalledAppAuthConnection.get_code",
-        return_value=1234,
-    )
-
     @classmethod
     def setUpClass(cls):
         super().setUpClass()
@@ -239,6 +238,11 @@ class GoogleInstalledAppAuthConnectionTest(GoogleTestCase):
     Tests for GoogleInstalledAppAuthConnection
     """
 
+    _ia_get_code_patcher = mock.patch(
+        "libcloud.common.google.GoogleInstalledAppAuthConnection.get_code",
+        return_value=1234,
+    )
+
     def setUp(self):
         GoogleInstalledAppAuthConnection.conn_class = GoogleAuthMockHttp
         self.mock_scopes = ["https://www.googleapis.com/auth/foo"]
@@ -272,6 +276,56 @@ class GoogleInstalledAppAuthConnectionTest(GoogleTestCase):
         self.assertTrue("refresh_token" in new_token2)
 
 
+class GoogleInstalledAppAuthConnectionFirstLoginTest(LibcloudTestCase):
+    def setUp(self):
+        GoogleInstalledAppAuthConnection.conn_class = GoogleAuthMockHttp
+        self.mock_scopes = ["https://www.googleapis.com/auth/foo"]
+        kwargs = {"scopes": self.mock_scopes}
+        self.conn = GoogleInstalledAppAuthConnection(*GCE_PARAMS, **kwargs)
+
+    def test_it_receives_the_code_that_google_sends_via_local_loopback(self):
+        expected_code = "1234ABC"
+        received_code = self._do_first_sign_in(expected_code=expected_code, state=self.conn._state)
+        self.assertEqual(received_code, expected_code)
+
+    def test_it_aborts_if_state_is_suspicious(self):
+        received_code = self._do_first_sign_in(
+            expected_code="1234ABC", state=self.conn._state + "very suspicious"
+        )
+        self.assertEqual(received_code, None)
+
+    def _do_first_sign_in(self, expected_code, state):
+        """
+        :param expected_code: The code that the fake Google sign-in local GET request will have in its query.
+        :type expected_code: `str`
+        :param state: The state that the fake Google sign-in local GET request will have in its query.
+        :type state: `str`
+        :return: The code that was extracted through local loopback.
+        :rtype: `Optional[str]`
+        """
+        received_code = None
+
+        def _get_code():
+            nonlocal received_code
+            received_code = self.conn.get_code()
+
+        def _send_code():
+            target_url = self.conn._redirect_uri_with_port
+            params = {"state": state, "code": expected_code}
+            params = urllib.parse.urlencode(params, quote_via=urllib.parse.quote)
+            requests.get(url=target_url, params=params)
+
+        fake_sign_in_thread = threading.Thread(target=_get_code)
+        fake_google_response = threading.Thread(target=_send_code)
+
+        fake_sign_in_thread.start()
+        fake_google_response.start()
+        fake_google_response.join()
+        fake_sign_in_thread.join()
+
+        return received_code
+
+
 class GoogleAuthTypeTest(GoogleTestCase):
     def test_guess(self):
         self.assertEqual(GoogleAuthType.guess_type(GCE_PARAMS_IA[0]), GoogleAuthType.IA)
@@ -295,6 +349,12 @@ class GoogleAuthTypeTest(GoogleTestCase):
 
 
 class GoogleOAuth2CredentialTest(GoogleTestCase):
+
+    _ia_get_code_patcher = mock.patch(
+        "libcloud.common.google.GoogleInstalledAppAuthConnection.get_code",
+        return_value=1234,
+    )
+
     def test_init_oauth2(self):
         kwargs = {"auth_type": GoogleAuthType.IA}
         cred = GoogleOAuth2Credential(*GCE_PARAMS, **kwargs)