Repository: kubeflow/pipelines

Issue:
[sdk] Error 400 authenticating via Google IAP
### Environment

*  KFP version:
?
*  KFP SDK version: 
1.8.11
*  All dependencies version:
kfp                      1.8.11
kfp-pipeline-spec        0.1.14
kfp-server-api           1.8.1


### Steps to reproduce

I followed the steps described in the documentation to connect to Kubeflow Pipelines in a full Kubeflow deployment with authentication through IAP:
https://www.kubeflow.org/docs/distributions/gke/pipelines/authentication-sdk/#connecting-to-kubeflow-pipelines-in-a-full-kubeflow-deployment

Following the Oauth link from the SDK:
_3. To connect to Kubeflow Pipelines public endpoint, initiate SDK client like the following:_
```
import kfp
client = kfp.Client(host='https://<KF_NAME>.endpoints.<PROJECT>.cloud.goog/pipeline',
    client_id='<AAAAAAAAAAAAAAAAAAAAAA>.apps.googleusercontent.com',
    other_client_id='<BBBBBBBBBBBBBBBBBBB>.apps.googleusercontent.com',
    other_client_secret='<CCCCCCCCCCCCCCCCCCCC>')
```
I get an Error 400: 
![grafik](https://user-images.githubusercontent.com/3743016/160302422-7959f30b-ad43-45b9-9f6b-6611700494e8.png)


### Expected result
I should get an authentication screen and then a code.


### Materials and Reference
This error seems to a deprecation of the copy-paste workflow for oauth authentification at the google side, as discussed here:
https://stackoverflow.com/questions/71318804/google-oauth-2-0-failing-with-error-400-invalid-request-for-some-client-id-but/71327990#71327990

I also implemented the workaround discussed here: https://stackoverflow.com/a/71331562
essentially "Replace urn:ietf:wg:oauth:2.0:oob with http://localhost:1/"

It is not sufficient to just change the `redirect_uri` parameter in url returned in the  vanilla 1.8.11. A change of this parameter in the source code is required as well.
This seems to work in my quick POC patch of KFP v1.8.11: https://github.com/votti/pipelines/tree/fix_redirect_uri_1.8.11 
Installable via: `pip install "git+https://github.com/votti/pipelines@fix_redirect_uri_1.7#subdirectory=sdk/python"`



--
Impacted by this bug? Give it a 👍. We prioritise the issues with the most 👍.

fix(sdk): Workaround for deprecated Google OAuth oob flow
**Description of your changes:**

This is a workaround for the deprecation of copy-paste based
OAuth out-of-band (oob) flow for Google Cloud ( #7474 ).

As described in (1) deprecation for this workflow started on Feb 28, 2022, thus the current authentication workflow is currently broken.

The workaround implemented here was found in (2) and consists of replacing the `redirect_uri` to `http://localhost:1` and capturing the return code form the error URL.  
While this is arguably a suboptimal user experience, at least the workflow works again. Maybe a Google Oauth expert could suggest a better solution?

References:
1) https://developers.googleblog.com/2022/02/making-oauth-flows-safer.html#disallowed-oob
2) Workaround from: https://stackoverflow.com/questions/71318804/google-oauth-2-0-failing-with-error-400-invalid-request-for-some-client-id-but/71327990#71327990




Discussion:
Hi @votti, feel free to send a PR and we will take a look. If it involves an interface change, we would need a design. Thank you!
@ji-yaqi Have you all had time to review this related PR? https://github.com/kubeflow/pipelines/pull/7526
Thanks for your pull request! It looks like this may be your first contribution to a Google open source project. Before we can look at your pull request, you'll need to sign a Contributor License Agreement (CLA).

For more information, open the [CLA check for this pull request](https://github.com/kubeflow/pipelines/pull/7526/checks?check_run_id=5855097663).
[APPROVALNOTIFIER] This PR is **NOT APPROVED**

This pull-request has been approved by:
To complete the [pull request process](https://git.k8s.io/community/contributors/guide/owners.md#the-code-review-process), please assign **connor-mccarthy** after the PR has been reviewed.
You can assign the PR to them by writing `/assign @connor-mccarthy` in a comment when ready.

The full list of commands accepted by this bot can be found [here](https://go.k8s.io/bot-commands?repo=kubeflow%2Fpipelines).

<details open>
Needs approval from an approver in each of these files:

- **[sdk/OWNERS](https://github.com/kubeflow/pipelines/blob/master/sdk/OWNERS)**

Approvers can indicate their approval by writing `/approve` in a comment
Approvers can cancel approval by writing `/approve cancel` in a comment
</details>
<!-- META={"approvers":["connor-mccarthy"]} -->
Hi @votti. Thanks for your PR.

I'm waiting for a [kubeflow](https://github.com/orgs/kubeflow/people) member to verify that this patch is reasonable to test. If it is, they should reply with `/ok-to-test` on its own line. Until that is done, I will not automatically test new commits in this PR, but the usual testing commands by org members will still work. Regular contributors should [join the org](https://www.kubeflow.org/docs/about/contributing/) to skip this step.

Once the patch is verified, the new status will be reflected by the `ok-to-test` label.

I understand the commands that are listed [here](https://go.k8s.io/bot-commands?repo=kubeflow%2Fpipelines).

<details>

Instructions for interacting with me using PR comments are available [here](https://git.k8s.io/community/contributors/guide/pull-requests.md).  If you have questions or suggestions related to my behavior, please file an issue against the [kubernetes/test-infra](https://github.com/kubernetes/test-infra/issues/new?title=Prow%20issue:) repository.
</details>

@Arhell @zijianjoy Does this mean this PR will get merged, a new kfp library version will be published and then we should all install it?
This PR uses the following flow:
* Load a URL in user's browser, which will ask for user to signin and approve access
* That page then redirects to a url which fails to load http://localhost:1/?code=SOMECODE&scope=...
* The user needs to copy the code out of the url

The recommended flow is:
* Start a webserver
* Load URL in user's browser to do signin
* Redirect to the local webserver
* Local webserver captures the code

See https://developers.google.com/people/quickstart/python for an example, which uses run_local_server on [google_auth_oauthlib.flow.InstalledAppFlow](https://google-auth-oauthlib.readthedocs.io/en/latest/reference/google_auth_oauthlib.flow.html) to do most of the work.

/assign @gkcalat for this issue.
> This PR uses the following flow:
> 
>     * Load a URL in user's browser, which will ask for user to signin and approve access
> 
>     * That page then redirects to a url which fails to load http://localhost:1/?code=SOMECODE&scope=...
> 
>     * The user needs to copy the code out of the url
> 
> 
> The recommended flow is:
> 
>     * Start a webserver
> 
>     * Load URL in user's browser to do signin
> 
>     * Redirect to the local webserver
> 
>     * Local webserver captures the code
> 
> 
> See https://developers.google.com/people/quickstart/python for an example, which uses run_local_server on [google_auth_oauthlib.flow.InstalledAppFlow](https://google-auth-oauthlib.readthedocs.io/en/latest/reference/google_auth_oauthlib.flow.html) to do most of the work.

@dsiegel Thank you very much for answering. Understood.

Kubeflow SDK still uses OOB flow (https://github.com/kubeflow/pipelines/blob/master/sdk/python/kfp/client/auth.py#L193, https://github.com/kubeflow/pipelines/blob/master/sdk/python/kfp/client/auth.py#L206). Google says we should change it (https://developers.google.com/identity/protocols/oauth2/resources/oob-migration#how-to-determine-if-your-app-is-using-the-oob-flow). 

This is why I think I received an email from GCP telling me to review one of my OAuth clients. It's a desktop client and I use it like this:


```
import kfp
client = kfp.Client(host=kubeflowClusterURL,
   client_id=webAppClientId,
   other_client_id=desktopClientId,
   other_client_secret=desktopClientSecret)
print(client.list_pipelines())

```

The following screenshot shows the key dates for compliance according to Google

![image](https://user-images.githubusercontent.com/4154361/187973003-ca12aba3-4592-43a0-afb9-be56be66ff66.png)

Any news about this? We, Google customers, need some time to try this before the deadline set by Google

Resolving patch:
diff --git a/sdk/python/kfp/client/auth.py b/sdk/python/kfp/client/auth.py
--- a/sdk/python/kfp/client/auth.py
+++ b/sdk/python/kfp/client/auth.py
@@ -12,10 +12,16 @@
 # See the License for the specific language governing permissions and
 # limitations under the License.
 
+from contextlib import contextmanager
 import json
 import logging
 import os
+from typing import Any, Callable, Dict, Generator, Iterable, Tuple, Union
+from urllib.parse import parse_qs
+from urllib.parse import urlparse
 from webbrowser import open_new_tab
+import wsgiref.simple_server
+import wsgiref.util
 
 import google.auth
 import google.auth.app_engine
@@ -32,15 +38,16 @@
 LOCAL_KFP_CREDENTIAL = os.path.expanduser('~/.config/kfp/credentials.json')
 
 
-def get_gcp_access_token():
+def get_gcp_access_token() -> Union[str, None]:
     """Gets GCP access token for the current Application Default Credentials.
 
-    If not set, returns None. For more information, see
-    https://cloud.google.com/sdk/gcloud/reference/auth/application-default/print-access-token
+    Returns:
+        GCP access token or None, if it is not set. For more information, see
+    https://cloud.google.com/sdk/gcloud/reference/auth/application-default/print-access-token.
     """
     token = None
     try:
-        creds, project = google.auth.default(
+        creds, _ = google.auth.default(
             scopes=['https://www.googleapis.com/auth/cloud-platform'])
         if not creds.valid:
             auth_req = Request()
@@ -52,26 +59,43 @@ def get_gcp_access_token():
     return token
 
 
-def get_auth_token(client_id, other_client_id, other_client_secret):
-    """Gets auth token from default service account or user account."""
+def get_auth_token(client_id: str, other_client_id: str,
+                   other_client_secret: str) -> Tuple[Union[str, None], bool]:
+    """Gets auth token from default service account or user account.
+
+    Returns:
+        Tuple of (ID token or None, if not found, and a boolean
+        indicating whether a refresh token has been saved locally)
+    """
+    is_refresh_token = True
     if os.path.exists(LOCAL_KFP_CREDENTIAL):
         # fetch IAP auth token using the locally stored credentials.
         with open(LOCAL_KFP_CREDENTIAL, 'r') as f:
             credentials = json.load(f)
         if client_id in credentials:
-            return id_token_from_refresh_token(
-                credentials[client_id]['other_client_id'],
-                credentials[client_id]['other_client_secret'],
-                credentials[client_id]['refresh_token'], client_id)
+            saved_refresh_token = credentials[client_id].get('refresh_token')
+            saved_other_client_id = credentials[client_id].get(
+                'other_client_id')
+            saved_other_client_secret = credentials[client_id].get(
+                'other_client_secret')
+            if None not in {
+                    saved_refresh_token, saved_other_client_id,
+                    saved_other_client_secret
+            }:
+                return id_token_from_refresh_token(saved_other_client_id,
+                                                   saved_other_client_secret,
+                                                   saved_refresh_token,
+                                                   client_id), is_refresh_token
     if other_client_id is None or other_client_secret is None:
         # fetch IAP auth token: service accounts
         token = get_auth_token_from_sa(client_id)
+        is_refresh_token = False
     else:
         # fetch IAP auth token: user account
         # Obtain the ID token for provided Client ID with user accounts.
         # Flow: get authorization code -> exchange for refresh token -> obtain
         # and return ID token
-        refresh_token = get_refresh_token_from_client_id(
+        refresh_token, is_refresh_token = get_refresh_token_from_client_id(
             other_client_id, other_client_secret)
         credentials = {}
         if os.path.exists(LOCAL_KFP_CREDENTIAL):
@@ -80,7 +104,10 @@ def get_auth_token(client_id, other_client_id, other_client_secret):
         credentials[client_id] = {}
         credentials[client_id]['other_client_id'] = other_client_id
         credentials[client_id]['other_client_secret'] = other_client_secret
-        credentials[client_id]['refresh_token'] = refresh_token
+        if is_refresh_token:
+            credentials[client_id]['refresh_token'] = refresh_token
+        else:
+            credentials[client_id]['access_token'] = refresh_token
         # TODO: handle the case when the refresh_token expires, which only
         # happens if the refresh_token is not used once for six months.
         if not os.path.exists(os.path.dirname(LOCAL_KFP_CREDENTIAL)):
@@ -90,13 +117,14 @@ def get_auth_token(client_id, other_client_id, other_client_secret):
         token = id_token_from_refresh_token(other_client_id,
                                             other_client_secret, refresh_token,
                                             client_id)
-    return token
+    return token, is_refresh_token
 
 
-def get_auth_token_from_sa(client_id):
+def get_auth_token_from_sa(client_id: str) -> Union[str, None]:
     """Gets auth token from default service account.
 
-    If no service account credential is found, returns None.
+    Returns:
+        Authorization token or None, if not found.
     """
     service_account_credentials = get_service_account_credentials(client_id)
     if service_account_credentials:
@@ -104,16 +132,24 @@ def get_auth_token_from_sa(client_id):
     return None
 
 
-def get_service_account_credentials(client_id):
-    # Figure out what environment we're running in and get some preliminary
-    # information about the service account.
+def get_service_account_credentials(
+        client_id: str
+) -> Union[google.oauth2.service_account.Credentials, None]:
+    """Figure out what environment we're running in and get some preliminary
+    information about the service account.
+
+    Args:
+        client_id: OAuth client ID.
+
+    Returns:
+        OAuth2 credentials or None.
+    """
     bootstrap_credentials, _ = google.auth.default(scopes=[IAM_SCOPE])
     if isinstance(bootstrap_credentials, google.oauth2.credentials.Credentials):
         logging.info('Found OAuth2 credentials and skip SA auth.')
         return None
-    elif isinstance(bootstrap_credentials, google.auth.app_engine.Credentials):
+    if isinstance(bootstrap_credentials, google.auth.app_engine.Credentials):
         requests_toolbelt.adapters.appengine.monkeypatch()
-
     # For service account's using the Compute Engine metadata service,
     # service_account_email isn't available until refresh is called.
     bootstrap_credentials.refresh(Request())
@@ -138,7 +174,6 @@ def get_service_account_credentials(client_id):
     else:
         # A Signer object can sign a JWT using the service account's key.
         signer = bootstrap_credentials.signer
-
     # Construct OAuth 2.0 service account credentials using the signer
     # and email acquired from the bootstrap credentials.
     return google.oauth2.service_account.Credentials(
@@ -148,7 +183,9 @@ def get_service_account_credentials(client_id):
         additional_claims={'target_audience': client_id})
 
 
-def get_google_open_id_connect_token(service_account_credentials):
+def get_google_open_id_connect_token(
+    service_account_credentials: google.oauth2.service_account.Credentials
+) -> str:
     """Gets an OpenID Connect token issued by Google for the service account.
 
     This function:
@@ -164,9 +201,11 @@ def get_google_open_id_connect_token(service_account_credentials):
     The HTTP/REST example on that page describes the JWT structure and
     demonstrates how to call the token endpoint. (The example on that page
     shows how to get an OAuth2 access token; this code is using a
-    modified version of it to get an OpenID Connect token.)
-    """
+    modified version of it to get an OpenID Connect token).
 
+    Returns:
+        OAuth ID token.
+    """
     service_account_jwt = (
         service_account_credentials._make_authorization_grant_assertion())
     request = google.auth.transport.requests.Request()
@@ -179,40 +218,186 @@ def get_google_open_id_connect_token(service_account_credentials):
     return token_response['id_token']
 
 
-def get_refresh_token_from_client_id(client_id, client_secret):
+def get_refresh_token_from_client_id(client_id: str,
+                                     client_secret: str) -> Tuple[str, bool]:
     """Obtains the ID token for provided Client ID with user accounts.
-
     Flow: get authorization code -> exchange for refresh token -> obtain and
     return ID token.
+
+    Args:
+        client_id: OAuth client ID.
+        client_secret: OAuth client secret.
+            See https://console.cloud.google.com/apis/credentials.
+
+    Returns:
+        Tuple of (OAuth short-lived access token or long-lived refresh token,
+        and a boolean indicating whether the returned token is refresh_token)
     """
-    auth_code = get_auth_code(client_id)
-    return get_refresh_token_from_code(auth_code, client_id, client_secret)
+    auth_code, redirect_uri = get_auth_code(client_id)
+    token, is_refresh_token = get_refresh_token_from_code(
+        auth_code, client_id, client_secret, redirect_uri)
+    return token, is_refresh_token
+
+
+def get_auth_code(client_id: str) -> Tuple[str, str]:
+    """Retrieves authorization token using Loopback flow.
 
+    Args:
+        client_id: OAuth client ID. To retrieve it, visit
+          https://console.cloud.google.com/apis/credentials.
 
-def get_auth_code(client_id):
-    auth_url = 'https://accounts.google.com/o/oauth2/v2/auth?client_id=%s&response_type=code&scope=openid%%20email&access_type=offline&redirect_uri=urn:ietf:wg:oauth:2.0:oob' % client_id
+    Returns:
+        A tuple of (authorization token, redirect_uri parameter).
+
+    Raises:
+        ValueError: If the provided authorization_response is empty.
+    """
+    host = 'localhost'
+    port = 9901
+    redirect_uri = f'http://{host}:{port}'
+    auth_url = ('https://accounts.google.com/o/oauth2/v2/auth?'
+                f'client_id={client_id}&response_type=code&'
+                'scope=openid%20email&access_type=offline&'
+                f'redirect_uri={redirect_uri}')
+    authorization_response = None
+    if ('SSH_CONNECTION' in os.environ) or ('SSH_CLIENT' in os.environ):
+        try:
+            print((
+                'SSH connection detected. Please follow the instructions below.'
+                ' Otherwise, press CTRL+C if you are not connected via SSH.'))
+            authorization_response = get_auth_response_ssh(host, port, auth_url)
+        except KeyboardInterrupt:
+            authorization_response = None
+            logging.warning('User pressed CTRL+C. Trying to open browser...')
+    if authorization_response is None:
+        try:
+            print(('Using a local web-server. Please follow the instructions '
+                   'below. Otherwise, press CTRL+C to cancel and manually '
+                   'copy-paste the response URL.'))
+            authorization_response = get_auth_response_local(
+                host, port, auth_url)
+        except KeyboardInterrupt:
+            logging.warning('User pressed CTRL+C. See instructions below.')
+            authorization_response = get_auth_response_ssh(host, port, auth_url)
+        except OSError as err:
+            logging.warning(
+                ('%s.\n Error occurred while creating a local web-server. '
+                 'Possibly http://%s:%i is allocated to '
+                 'another process. Falling back to manual mode. '
+                 'See instructions below.'), err, host, port)
+            authorization_response = get_auth_response_ssh(host, port, auth_url)
+    if authorization_response is None:
+        raise ValueError(
+            'Authorization response URL is empty. This may be caused by '
+            f'corrupted or expired credentials in {LOCAL_KFP_CREDENTIAL}'
+            '. Try renaming or moving them to another directory before '
+            'running again.')
+    token = fetch_auth_token_from_response(authorization_response)
+    return token, redirect_uri
+
+
+def get_auth_response_ssh(host: str, port: int, auth_url: str) -> str:
+    """Fetches OAuth authorization response URL for remote SSH connection.
+
+    Args:
+        host: Hostname in redirect_uri.
+        port: Port in redirect_uri.
+        auth_url: OAuth request URL.
+
+    Returns:
+        A URL containing authorization code.
+    """
     print(auth_url)
-    open_new_tab(auth_url)
-    return input(
-        "If there's no browser window prompt, please direct to the URL above, "
-        'then copy and paste the authorization code here: ')
+    authorization_response = input(
+        'Carefully follow these steps: (1) open the URL above in your'
+        ' browser, (2) authenticate and copy a url of the response page'
+        f' that starts with http://{host}:{port}..., and (3) paste it'
+        ' below:\n')
+    return authorization_response
+
+
+def get_auth_response_local(host: str, port: int,
+                            auth_url: str) -> Union[str, None]:
+    """Fetches OAuth authorization response URL using a local web-server.
+
+    Args:
+        host: Hostname of the server.
+        port: Port of the server.
+        auth_url: OAuth request URL.
+
+    Returns:
+        A URL containing authorization code.
+    """
+    with get_local_server_app(host, port) as (local_server, wsgi_app):
+        open_new_tab(auth_url)
+        print(f'Please visit this URL to authorize Kubeflow SDK: {auth_url}')
+        print((f'Make sure that http://{host}:{port} is added to Authorized'
+               ' redirect URIs for your OAuth 2.0 Client ID. Check it here:'
+               ' https://console.cloud.google.com/apis/credentials'))
+        local_server.handle_request()
+        return wsgi_app.last_request_uri
+
+
+def get_refresh_token_from_code(auth_code: str, client_id: str,
+                                client_secret: str,
+                                redirect_uri: str) -> Tuple[str, bool]:
+    """Returns refresh or access token from authorization code.
 
+    Args:
+        auth_code: OAuth authorization code.
+        client_id: OAuth client ID.
+        client_secret: OAuth client secret.
+        redirect_uri: Redirect uri used to obtain auth_code.
 
-def get_refresh_token_from_code(auth_code, client_id, client_secret):
+    Returns:
+        Tuple of (OAuth short-lived access token or long-lived refresh token,
+        and a boolean indicating whether the returned token is refresh_token)
+
+    Raises:
+        ValueError: If HTTP request returns
+            a requests.exceptions.HTTPError.
+    """
     payload = {
         'code': auth_code,
         'client_id': client_id,
         'client_secret': client_secret,
-        'redirect_uri': 'urn:ietf:wg:oauth:2.0:oob',
+        'redirect_uri': redirect_uri,
         'grant_type': 'authorization_code'
     }
     res = requests.post(OAUTH_TOKEN_URI, data=payload)
-    res.raise_for_status()
-    return str(json.loads(res.text)[u'refresh_token'])
+    try:
+        res.raise_for_status()
+    except requests.exceptions.HTTPError as err:
+        raise ValueError(
+            ('Some HTTPErrors are caused by expired credentials in '
+             f'{LOCAL_KFP_CREDENTIAL} Try renaming or moving '
+             'them to another directory before running again.')) from err
+    parsed_res = json.loads(res.text)
+    token = parsed_res.get('refresh_token')
+    is_refresh_token = True
+    if token is None:
+        token = parsed_res.get('access_token')
+        is_refresh_token = False
+    return str(token), is_refresh_token
+
 
+def id_token_from_refresh_token(client_id: str, client_secret: str,
+                                refresh_token: str, audience: str) -> str:
+    """Returns ID token from refresh token.
 
-def id_token_from_refresh_token(client_id, client_secret, refresh_token,
-                                audience):
+    Args:
+        client_id: OAuth client ID.
+        client_secret: OAuth client secret.
+        refresh_token: OAuth refresh token.
+        audience: OAuth audience.
+
+    Returns:
+        OAuth ID token.
+
+    Raises:
+        ValueError: If HTTP request returns
+            a requests.exceptions.HTTPError.
+    """
     payload = {
         'client_id': client_id,
         'client_secret': client_secret,
@@ -221,5 +406,106 @@ def id_token_from_refresh_token(client_id, client_secret, refresh_token,
         'audience': audience
     }
     res = requests.post(OAUTH_TOKEN_URI, data=payload)
-    res.raise_for_status()
-    return str(json.loads(res.text)[u'id_token'])
+    try:
+        res.raise_for_status()
+    except requests.exceptions.HTTPError as err:
+        raise ValueError(
+            ('Some HTTPErrors are caused by expired credentials in '
+             f'{LOCAL_KFP_CREDENTIAL}. Try renaming or moving '
+             'them to another directory before running again.')) from err
+    return str(json.loads(res.text).get('id_token'))
+
+
+class RedirectWSGIApp:
+    """WSGI app to handle the authorization redirect.
+
+    Stores the request URI and displays the given success message.
+    """
+
+    def __init__(self, success_message: str) -> None:
+        """
+        Args:
+            success_message: The message to display in the web browser
+                the authorization flow is complete.
+        """
+        self.last_request_uri = None
+        self._success_message = success_message
+
+    def __call__(
+        self, environ: Dict[str, Any], start_response: Callable[[str, list],
+                                                                Callable[...,
+                                                                         None]]
+    ) -> Iterable[bytes]:
+        """WSGI Callable. Updates environment dictionary with parameters
+        required for WSGI and returns it.
+
+        Args:
+            environ: The WSGI environment.
+            start_response: The WSGI start_response
+                callable.
+
+        Returns:
+            The response body.
+        """
+        wsgiref.util.setup_testing_defaults(environ)
+        start_response('200 OK',
+                       [('Content-type', 'text/plain; charset=utf-8')])
+        self.last_request_uri = wsgiref.util.request_uri(environ)
+        return [self._success_message.encode('utf-8')]
+
+
+@contextmanager
+def get_local_server_app(
+    host: str, port: int
+) -> Generator[Tuple[wsgiref.simple_server.WSGIServer, RedirectWSGIApp], None,
+               None]:
+    """Creates a local web-server for given host and port.
+
+    Args:
+        host: Hostname of the server.
+        port: Port of the server.
+
+    Returns:
+        Tuple of (a local server instance, WSGI app that handles
+            the authorization redirect).
+    """
+    success_message = ('Kubeflow SDK authentication is completed.'
+                       ' You may close this window now.')
+    wsgi_app = RedirectWSGIApp(success_message)
+    wsgiref.simple_server.WSGIServer.allow_reuse_address = False
+    local_server = wsgiref.simple_server.make_server(
+        host,
+        port,
+        wsgi_app,
+        handler_class=wsgiref.simple_server.WSGIRequestHandler)
+    try:
+        yield local_server, wsgi_app
+    finally:
+        local_server.server_close()
+        del wsgi_app
+
+
+def fetch_auth_token_from_response(url: str) -> str:
+    """Fetches authorization code for OAuth2.0 Loopback flow.
+
+    Args:
+        url: A string containing the response URL.
+
+    Returns:
+        An access code.
+
+    Raises:
+        KeyError: If no authorization code is found in the provided url.
+    """
+    parsed_url = urlparse(url)
+    parsed_query = parse_qs(parsed_url.query)
+    access_code = parsed_query.get('code')
+    if access_code is None:
+        raise KeyError((
+            'Authorization code is missing or empty in the response.'
+            ' Please, try again or check '
+            'https://www.kubeflow.org/docs/distributions/gke/deploy/oauth-setup'
+        ))
+    if isinstance(access_code, list):
+        access_code = str(access_code.pop(0))
+    return access_code
diff --git a/sdk/python/kfp/client/client.py b/sdk/python/kfp/client/client.py
--- a/sdk/python/kfp/client/client.py
+++ b/sdk/python/kfp/client/client.py
@@ -259,9 +259,8 @@ def _load_config(
             token = existing_token
             self._is_refresh_token = False
         elif client_id:
-            token = auth.get_auth_token(client_id, other_client_id,
-                                        other_client_secret)
-            self._is_refresh_token = True
+            token, self._is_refresh_token = auth.get_auth_token(
+                client_id, other_client_id, other_client_secret)
         elif self._is_inverse_proxy_host(host):
             token = auth.get_gcp_access_token()
             self._is_refresh_token = False
@@ -477,13 +476,13 @@ def create_experiment(
                 resource_references=resource_references)
             experiment = self._experiment_api.create_experiment(body=experiment)
 
-        link = f"{self._get_url_prefix()}/#/experiments/details/{experiment.id}"
+        link = f'{self._get_url_prefix()}/#/experiments/details/{experiment.id}'
         if self._is_ipython():
             import IPython
             html = f'<a href="{link}" target="_blank" >Experiment details</a>.'
             IPython.display.display(IPython.display.HTML(html))
         else:
-            print(f"Experiment details: {link}")
+            print(f'Experiment details: {link}')
 
         return experiment
 
@@ -784,13 +783,13 @@ def run_pipeline(
 
         response = self._run_api.create_run(body=run_body)
 
-        link = f"{self._get_url_prefix()}/#/runs/details/{response.run.id}"
+        link = f'{self._get_url_prefix()}/#/runs/details/{response.run.id}'
         if self._is_ipython():
             import IPython
             html = (f'<a href="{link}" target="_blank" >Run details</a>.')
             IPython.display.display(IPython.display.HTML(html))
         else:
-            print(f"Run details: {link}")
+            print(f'Run details: {link}')
 
         return response.run
 
@@ -1377,13 +1376,13 @@ def upload_pipeline(
         validate_pipeline_resource_name(pipeline_name)
         response = self._upload_api.upload_pipeline(
             pipeline_package_path, name=pipeline_name, description=description)
-        link = f"{self._get_url_prefix()}/#/pipelines/details/{response.id}"
+        link = f'{self._get_url_prefix()}/#/pipelines/details/{response.id}'
         if self._is_ipython():
             import IPython
             html = f'<a href="{link}" target="_blank" >Pipeline details</a>.'
             IPython.display.display(IPython.display.HTML(html))
         else:
-            print(f"Pipeline details: {link}")
+            print(f'Pipeline details: {link}')
 
         return response
 
@@ -1426,13 +1425,13 @@ def upload_pipeline_version(
         response = self._upload_api.upload_pipeline_version(
             pipeline_package_path, **kwargs)
 
-        link = f"{self._get_url_prefix()}/#/pipelines/details/{response.id}"
+        link = f'{self._get_url_prefix()}/#/pipelines/details/{response.id}'
         if self._is_ipython():
             import IPython
             html = f'<a href="{link}" target="_blank" >Pipeline details</a>.'
             IPython.display.display(IPython.display.HTML(html))
         else:
-            print(f"Pipeline details: {link}")
+            print(f'Pipeline details: {link}')
 
         return response
 


Test patch: