Revert "depot_tools: Stop using oauth2client"

This reverts commit 55e5853e5ce49fda2589948c5a0fa6a56d4b3015.

Reason for revert:

  File "PRESUBMIT.py", line 11, in CheckChangeOnCommit
    return input_api.canned_checks.CheckChangedLUCIConfigs(input_api, output_api)
  File "/usr/local/google/home/abennetts/cr/depot_tools/presubmit_canned_checks.py", line 1421, in CheckChangedLUCIConfigs
    acc_tkn = authenticator.get_access_token()
  File "/usr/local/google/home/abennetts/cr/depot_tools/auth.py", line 414, in get_access_token
    if not self._external_token and allow_user_interaction:
AttributeError: 'Authenticator' object has no attribute '_external_token'


Original change's description:
> depot_tools: Stop using oauth2client
> 
> Bug: 1001756
> Change-Id: I8a0ca2b0f44b20564a9d3192543a7a69788d8d87
> Reviewed-on: https://chromium-review.googlesource.com/c/chromium/tools/depot_tools/+/1854898
> Commit-Queue: Edward Lesmes <ehmaldonado@chromium.org>
> Reviewed-by: Vadim Shtayura <vadimsh@chromium.org>

TBR=vadimsh@chromium.org,ehmaldonado@chromium.org

Change-Id: I94cf38e82e53e51c66efcb99c51f0e1418e86f49
No-Presubmit: true
No-Tree-Checks: true
No-Try: true
Bug: 1001756, 1015285
Reviewed-on: https://chromium-review.googlesource.com/c/chromium/tools/depot_tools/+/1866184
Reviewed-by: Edward Lesmes <ehmaldonado@chromium.org>
Commit-Queue: Edward Lesmes <ehmaldonado@chromium.org>
diff --git a/auth.py b/auth.py
index 84011b3..e0afec9 100644
--- a/auth.py
+++ b/auth.py
@@ -21,6 +21,7 @@
 import subprocess2
 
 from third_party import httplib2
+from third_party.oauth2client import client
 
 
 # depot_tools/.
@@ -54,8 +55,8 @@
     """True if this AccessToken should be refreshed."""
     if self.expires_at is not None:
       now = now or datetime.datetime.utcnow()
-      # Allow 30s of clock skew between client and backend.
-      now += datetime.timedelta(seconds=30)
+      # Allow 3 min of clock skew between client and backend.
+      now += datetime.timedelta(seconds=180)
       return now >= self.expires_at
     # Token without expiration time never expires.
     return False
@@ -99,8 +100,6 @@
   return bool(params.default_account_id)
 
 
-# TODO(crbug.com/1001756): Remove. luci-auth uses local auth if available,
-# making this unnecessary.
 def get_luci_context_access_token(scopes=OAUTH_SCOPE_EMAIL):
   """Returns a valid AccessToken from the local LUCI context auth server.
 
@@ -292,18 +291,18 @@
       help='Do not save authentication cookies to local disk.')
 
   # OAuth2 related options.
-  # TODO(crbug.com/1001756): Remove. No longer supported.
   parser.auth_group.add_option(
       '--auth-no-local-webserver',
       action='store_false',
       dest='use_local_webserver',
       default=default_config.use_local_webserver,
-      help='DEPRECATED. Do not use')
+      help='Do not run a local web server when performing OAuth2 login flow.')
   parser.auth_group.add_option(
       '--auth-host-port',
       type=int,
       default=default_config.webserver_port,
-      help='DEPRECATED. Do not use')
+      help='Port a local web server should listen on. Used only if '
+          '--auth-no-local-webserver is not set. [default: %default]')
   parser.auth_group.add_option(
       '--auth-refresh-token-json',
       help='DEPRECATED. Do not use')
@@ -373,25 +372,27 @@
     logging.debug('Using auth config %r', config)
 
   def has_cached_credentials(self):
-    """Returns True if credentials can be obtained.
+    """Returns True if long term credentials (refresh token) are in cache.
 
-    If returns False, get_access_token() later will probably ask for interactive
-    login by raising LoginRequiredError, unless local auth in configured.
+    Doesn't make network calls.
+
+    If returns False, get_access_token() later will ask for interactive login by
+    raising LoginRequiredError.
 
     If returns True, most probably get_access_token() won't ask for interactive
-    login, unless an external token is provided that has been revoked.
+    login, though it is not guaranteed, since cached token can be already
+    revoked and there's no way to figure this out without actually trying to use
+    it.
     """
     with self._lock:
-      return bool(self._get_luci_auth_token())
+      return bool(self._get_cached_credentials())
 
   def get_access_token(self, force_refresh=False, allow_user_interaction=False,
                        use_local_auth=True):
     """Returns AccessToken, refreshing it if necessary.
 
     Args:
-      TODO(crbug.com/1001756): Remove. luci-auth doesn't support
-      force-refreshing tokens.
-      force_refresh: Ignored,
+      force_refresh: forcefully refresh access token even if it is not expired.
       allow_user_interaction: True to enable blocking for user input if needed.
       use_local_auth: default to local auth if needed.
 
@@ -400,41 +401,53 @@
       LoginRequiredError if user interaction is required, but
           allow_user_interaction is False.
     """
+    def get_loc_auth_tkn():
+      exi = sys.exc_info()
+      if not use_local_auth:
+        logging.error('Failed to create access token')
+        raise
+      try:
+        self._access_token = get_luci_context_access_token()
+        if not self._access_token:
+          logging.error('Failed to create access token')
+          raise
+        return self._access_token
+      except LuciContextAuthError:
+        logging.exception('Failed to use local auth')
+        raise exi[0], exi[1], exi[2]
+
     with self._lock:
-      if self._access_token and not self._access_token.needs_refresh():
-        return self._access_token
-
-      # Token expired or missing. Maybe some other process already updated it,
-      # reload from the cache.
-      self._access_token = self._get_luci_auth_token()
-      if self._access_token and not self._access_token.needs_refresh():
-        return self._access_token
-
-      # Nope, still expired, need to run the refresh flow.
-      if not self._external_token and allow_user_interaction:
-        logging.debug('Launching luci-auth login')
-        self._access_token = self._run_oauth_dance()
-      if self._access_token and not self._access_token.needs_refresh():
-        return self._access_token
-
-      # TODO(crbug.com/1001756): Remove. luci-auth uses local auth if it exists.
-      # Refresh flow failed. Try local auth.
-      if use_local_auth:
+      if force_refresh:
+        logging.debug('Forcing access token refresh')
         try:
-          self._access_token = get_luci_context_access_token()
-        except LuciContextAuthError:
-          logging.exception('Failed to use local auth')
-      if self._access_token and not self._access_token.needs_refresh():
-        return self._access_token
+          self._access_token = self._create_access_token(allow_user_interaction)
+          return self._access_token
+        except LoginRequiredError:
+          return get_loc_auth_tkn()
 
-      # Give up.
-      logging.error('Failed to create access token')
-      raise LoginRequiredError(self._scopes)
+      # Load from on-disk cache on a first access.
+      if not self._access_token:
+        self._access_token = self._load_access_token()
+
+      # Refresh if expired or missing.
+      if not self._access_token or self._access_token.needs_refresh():
+        # Maybe some other process already updated it, reload from the cache.
+        self._access_token = self._load_access_token()
+        # Nope, still expired, need to run the refresh flow.
+        if not self._access_token or self._access_token.needs_refresh():
+          try:
+            self._access_token = self._create_access_token(
+                allow_user_interaction)
+          except LoginRequiredError:
+            get_loc_auth_tkn()
+
+      return self._access_token
 
   def authorize(self, http):
     """Monkey patches authentication logic of httplib2.Http instance.
 
     The modified http.request method will add authentication headers to each
+    request and will refresh access_tokens when a 401 is received on a
     request.
 
     Args:
@@ -444,6 +457,7 @@
        A modified instance of http that was passed in.
     """
     # Adapted from oauth2client.OAuth2Credentials.authorize.
+
     request_orig = http.request
 
     @functools.wraps(request_orig)
@@ -453,37 +467,92 @@
         connection_type=None):
       headers = (headers or {}).copy()
       headers['Authorization'] = 'Bearer %s' % self.get_access_token().token
-      return request_orig(
+      resp, content = request_orig(
           uri, method, body, headers, redirections, connection_type)
+      if resp.status in client.REFRESH_STATUS_CODES:
+        logging.info('Refreshing due to a %s', resp.status)
+        access_token = self.get_access_token(force_refresh=True)
+        headers['Authorization'] = 'Bearer %s' % access_token.token
+        return request_orig(
+            uri, method, body, headers, redirections, connection_type)
+      else:
+        return (resp, content)
 
     http.request = new_request
     return http
 
   ## Private methods.
 
-  def _run_luci_auth_login(self):
-    """Run luci-auth login.
+  def _get_cached_credentials(self):
+    """Returns oauth2client.Credentials loaded from luci-auth."""
+    credentials = _get_luci_auth_credentials(self._scopes)
+
+    if not credentials:
+      logging.debug('No cached token')
+    else:
+      _log_credentials_info('cached token', credentials)
+
+    return credentials if (credentials and not credentials.invalid) else None
+
+  def _load_access_token(self):
+    """Returns cached AccessToken if it is not expired yet."""
+    logging.debug('Reloading access token from cache')
+    creds = self._get_cached_credentials()
+    if not creds or not creds.access_token or creds.access_token_expired:
+      logging.debug('Access token is missing or expired')
+      return None
+    return AccessToken(str(creds.access_token), creds.token_expiry)
+
+  def _create_access_token(self, allow_user_interaction=False):
+    """Mints and caches a new access token, launching OAuth2 dance if necessary.
+
+    Uses cached refresh token, if present. In that case user interaction is not
+    required and function will finish quietly. Otherwise it will launch 3-legged
+    OAuth2 flow, that needs user interaction.
+
+    Args:
+      allow_user_interaction: if True, allow interaction with the user (e.g.
+          reading standard input, or launching a browser).
 
     Returns:
-      AccessToken with credentials.
-    """
-    logging.debug('Running luci-auth login')
-    subprocess2.check_call(['luci-auth', 'login', '-scopes', self._scopes])
-    return self._get_luci_auth_token()
+      AccessToken.
 
-  def _get_luci_auth_token(self):
-    logging.debug('Running luci-auth token')
-    try:
-      out, err = subprocess2.check_call_out(
-          ['luci-auth', 'token', '-scopes', self._scopes, '-json-output', '-'],
-          stdout=subprocess2.PIPE, stderr=subprocess2.PIPE)
-      logging.debug('luci-auth token stderr:\n%s', err)
-      token_info = json.loads(out)
-      return AccessToken(
-          token_info['token'],
-          datetime.datetime.utcfromtimestamp(token_info['expiry']))
-    except subprocess2.CalledProcessError:
-      return None
+    Raises:
+      AuthenticationError on error or if authentication flow was interrupted.
+      LoginRequiredError if user interaction is required, but
+          allow_user_interaction is False.
+    """
+    logging.debug(
+        'Making new access token (allow_user_interaction=%r)',
+        allow_user_interaction)
+    credentials = self._get_cached_credentials()
+
+    # 3-legged flow with (perhaps cached) refresh token.
+    refreshed = False
+    if credentials and not credentials.invalid:
+      try:
+        logging.debug('Attempting to refresh access_token')
+        credentials.refresh(httplib2.Http())
+        _log_credentials_info('refreshed token', credentials)
+        refreshed = True
+      except client.Error as err:
+        logging.warning(
+            'OAuth error during access token refresh (%s). '
+            'Attempting a full authentication flow.', err)
+
+    # Refresh token is missing or invalid, go through the full flow.
+    if not refreshed:
+      if not allow_user_interaction:
+        logging.debug('Requesting user to login')
+        raise LoginRequiredError(self._scopes)
+      logging.debug('Launching OAuth browser flow')
+      credentials = _run_oauth_dance(self._scopes)
+      _log_credentials_info('new token', credentials)
+
+    logging.info(
+        'OAuth access_token refreshed. Expires in %s.',
+        credentials.token_expiry - datetime.datetime.utcnow())
+    return AccessToken(str(credentials.access_token), credentials.token_expiry)
 
 
 ## Private functions.
@@ -492,3 +561,44 @@
 def _is_headless():
   """True if machine doesn't seem to have a display."""
   return sys.platform == 'linux2' and not os.environ.get('DISPLAY')
+
+
+def _log_credentials_info(title, credentials):
+  """Dumps (non sensitive) part of client.Credentials object to debug log."""
+  if credentials:
+    logging.debug('%s info: %r', title, {
+        'access_token_expired': credentials.access_token_expired,
+        'has_access_token': bool(credentials.access_token),
+        'invalid': credentials.invalid,
+        'utcnow': datetime.datetime.utcnow(),
+        'token_expiry': credentials.token_expiry,
+    })
+
+
+def _get_luci_auth_credentials(scopes):
+  try:
+    token_info = json.loads(subprocess2.check_output(
+        ['luci-auth', 'token', '-scopes', scopes, '-json-output', '-'],
+        stderr=subprocess2.VOID))
+  except subprocess2.CalledProcessError:
+    return None
+
+  return client.OAuth2Credentials(
+      access_token=token_info['token'],
+      client_id=None,
+      client_secret=None,
+      refresh_token=None,
+      token_expiry=datetime.datetime.utcfromtimestamp(token_info['expiry']),
+      token_uri=None,
+      user_agent=None,
+      revoke_uri=None)
+
+
+def _run_oauth_dance(scopes):
+  """Perform full 3-legged OAuth2 flow with the browser.
+
+  Returns:
+    oauth2client.Credentials.
+  """
+  subprocess2.check_call(['luci-auth', 'login', '-scopes', scopes])
+  return _get_luci_auth_credentials(scopes)