Add OAuth2 support for end users (i.e. 3-legged flow with the browser).

This CL introduces new top level command for managing cached auth tokens:
  $ depot-tools-auth login codereview.chromium.org
  $ depot-tools-auth info codereview.chromium.org
  $ depot-tools-auth logout codereview.chromium.org

All scripts that use rietveld.Rietveld internally should be able to use cached
credentials created by 'depot-tools-auth' subcommand. Also 'depot-tools-auth'
is the only way to run login flow. If some scripts stumbles over expired or
revoked token, it dies with the error, asking user to run
'depot-tools-auth login <hostname>'.

Password login is still default. OAuth2 can be enabled by passing --oauth2 to
all scripts.

R=maruel@chromium.org
BUG=356813

Review URL: https://codereview.chromium.org/1074673002

git-svn-id: svn://svn.chromium.org/chrome/trunk/tools/depot_tools@294764 0039d316-1c4b-4281-b951-d872f2087c98
diff --git a/.gitignore b/.gitignore
index 4db19bf..5061e26 100644
--- a/.gitignore
+++ b/.gitignore
@@ -46,3 +46,7 @@
 /tests/subversion_config/servers
 /tests/svn/
 /tests/svnrepo/
+
+# Ignore "flag file" used by auth.py.
+# TODO(vadimsh): Remove this once OAuth2 is default.
+/USE_OAUTH2
diff --git a/auth.py b/auth.py
index 97520de..789db6a 100644
--- a/auth.py
+++ b/auth.py
@@ -1,11 +1,58 @@
-# Copyright (c) 2015 The Chromium Authors. All rights reserved.
+# Copyright 2015 The Chromium Authors. All rights reserved.
 # Use of this source code is governed by a BSD-style license that can be
 # found in the LICENSE file.
 
-"""Authentication related functions."""
+"""Google OAuth2 related functions."""
 
+import BaseHTTPServer
 import collections
+import datetime
+import functools
+import json
+import logging
 import optparse
+import os
+import socket
+import sys
+import threading
+import urllib
+import urlparse
+import webbrowser
+
+from third_party import httplib2
+from third_party.oauth2client import client
+from third_party.oauth2client import multistore_file
+
+
+# depot_tools/.
+DEPOT_TOOLS_DIR = os.path.dirname(os.path.abspath(__file__))
+
+
+# Google OAuth2 clients always have a secret, even if the client is an installed
+# application/utility such as this. Of course, in such cases the "secret" is
+# actually publicly known; security depends entirely on the secrecy of refresh
+# tokens, which effectively become bearer tokens. An attacker can impersonate
+# service's identity in OAuth2 flow. But that's generally fine as long as a list
+# of allowed redirect_uri's associated with client_id is limited to 'localhost'
+# or 'urn:ietf:wg:oauth:2.0:oob'. In that case attacker needs some process
+# running on user's machine to successfully complete the flow and grab refresh
+# token. When you have a malicious code running on your machine, you're screwed
+# anyway.
+# This particular set is managed by API Console project "chrome-infra-auth".
+OAUTH_CLIENT_ID = (
+    '446450136466-2hr92jrq8e6i4tnsa56b52vacp7t3936.apps.googleusercontent.com')
+OAUTH_CLIENT_SECRET = 'uBfbay2KCy9t4QveJ-dOqHtp'
+
+# List of space separated OAuth scopes for generated tokens. GAE apps usually
+# use userinfo.email scope for authentication.
+OAUTH_SCOPES = 'https://www.googleapis.com/auth/userinfo.email'
+
+# Path to a file with cached OAuth2 credentials used by default. It should be
+# a safe location accessible only to a current user: knowing content of this
+# file is roughly equivalent to knowing account password. Single file can hold
+# multiple independent tokens identified by token_cache_key (see Authenticator).
+OAUTH_TOKENS_CACHE = os.path.join(
+    os.path.expanduser('~'), '.depot_tools_oauth2_tokens')
 
 
 # Authentication configuration extracted from command line options.
@@ -18,6 +65,28 @@
 ])
 
 
+# OAuth access token with its expiration time (UTC datetime or None if unknown).
+AccessToken = collections.namedtuple('AccessToken', [
+    'token',
+    'expires_at',
+])
+
+
+class AuthenticationError(Exception):
+  """Raised on errors related to authentication."""
+
+
+class LoginRequiredError(AuthenticationError):
+  """Interaction with the user is required to authenticate."""
+
+  def __init__(self, token_cache_key):
+    # HACK(vadimsh): It is assumed here that the token cache key is a hostname.
+    msg = (
+        'You are not logged in. Please login first by running:\n'
+        '  depot-tools-auth login %s' % token_cache_key)
+    super(LoginRequiredError, self).__init__(msg)
+
+
 def make_auth_config(
     use_oauth2=None,
     save_cookies=None,
@@ -31,34 +100,42 @@
   """
   default = lambda val, d: val if val is not None else d
   return AuthConfig(
-      default(use_oauth2, False),
+      default(use_oauth2, _should_use_oauth2()),
       default(save_cookies, True),
-      default(use_local_webserver, True),
+      default(use_local_webserver, not _is_headless()),
       default(webserver_port, 8090))
 
 
-def add_auth_options(parser):
+def add_auth_options(parser, default_config=None):
   """Appends OAuth related options to OptionParser."""
-  default_config = make_auth_config()
+  default_config = default_config or make_auth_config()
   parser.auth_group = optparse.OptionGroup(parser, 'Auth options')
+  parser.add_option_group(parser.auth_group)
+
+  # OAuth2 vs password switch.
+  auth_default = 'use OAuth2' if default_config.use_oauth2 else 'use password'
   parser.auth_group.add_option(
       '--oauth2',
       action='store_true',
       dest='use_oauth2',
       default=default_config.use_oauth2,
-      help='Use OAuth 2.0 instead of a password.')
+      help='Use OAuth 2.0 instead of a password. [default: %s]' % auth_default)
   parser.auth_group.add_option(
       '--no-oauth2',
       action='store_false',
       dest='use_oauth2',
       default=default_config.use_oauth2,
-      help='Use password instead of OAuth 2.0.')
+      help='Use password instead of OAuth 2.0. [default: %s]' % auth_default)
+
+  # Password related options, deprecated.
   parser.auth_group.add_option(
       '--no-cookies',
       action='store_false',
       dest='save_cookies',
       default=default_config.save_cookies,
       help='Do not save authentication cookies to local disk.')
+
+  # OAuth2 related options.
   parser.auth_group.add_option(
       '--auth-no-local-webserver',
       action='store_false',
@@ -71,7 +148,6 @@
       default=default_config.webserver_port,
       help='Port a local web server should listen on. Used only if '
           '--auth-no-local-webserver is not set. [default: %default]')
-  parser.add_option_group(parser.auth_group)
 
 
 def extract_auth_config_from_options(options):
@@ -87,13 +163,387 @@
 
 
 def auth_config_to_command_options(auth_config):
-  """AuthConfig -> list of strings with command line options."""
+  """AuthConfig -> list of strings with command line options.
+
+  Omits options that are set to default values.
+  """
   if not auth_config:
     return []
-  opts = ['--oauth2' if auth_config.use_oauth2 else '--no-oauth2']
-  if not auth_config.save_cookies:
-    opts.append('--no-cookies')
-  if not auth_config.use_local_webserver:
-    opts.append('--auth-no-local-webserver')
-  opts.extend(['--auth-host-port', str(auth_config.webserver_port)])
+  defaults = make_auth_config()
+  opts = []
+  if auth_config.use_oauth2 != defaults.use_oauth2:
+    opts.append('--oauth2' if auth_config.use_oauth2 else '--no-oauth2')
+  if auth_config.save_cookies != auth_config.save_cookies:
+    if not auth_config.save_cookies:
+      opts.append('--no-cookies')
+  if auth_config.use_local_webserver != defaults.use_local_webserver:
+    if not auth_config.use_local_webserver:
+      opts.append('--auth-no-local-webserver')
+  if auth_config.webserver_port != defaults.webserver_port:
+    opts.extend(['--auth-host-port', str(auth_config.webserver_port)])
   return opts
+
+
+def get_authenticator_for_host(hostname, config):
+  """Returns Authenticator instance to access given host.
+
+  Args:
+    hostname: a naked hostname or http(s)://<hostname>[/] URL. Used to derive
+        a cache key for token cache.
+    config: AuthConfig instance.
+
+  Returns:
+    Authenticator object.
+  """
+  hostname = hostname.lower().rstrip('/')
+  # Append some scheme, otherwise urlparse puts hostname into parsed.path.
+  if '://' not in hostname:
+    hostname = 'https://' + hostname
+  parsed = urlparse.urlparse(hostname)
+  if parsed.path or parsed.params or parsed.query or parsed.fragment:
+    raise AuthenticationError(
+        'Expecting a hostname or root host URL, got %s instead' % hostname)
+  return Authenticator(parsed.netloc, config)
+
+
+class Authenticator(object):
+  """Object that knows how to refresh access tokens when needed.
+
+  Args:
+    token_cache_key: string key of a section of the token cache file to use
+        to keep the tokens. See hostname_to_token_cache_key.
+    config: AuthConfig object that holds authentication configuration.
+  """
+
+  def __init__(self, token_cache_key, config):
+    assert isinstance(config, AuthConfig)
+    assert config.use_oauth2
+    self._access_token = None
+    self._config = config
+    self._lock = threading.Lock()
+    self._token_cache_key = token_cache_key
+
+  def login(self):
+    """Performs interactive login flow if necessary.
+
+    Raises:
+      AuthenticationError on error or if interrupted.
+    """
+    return self.get_access_token(
+        force_refresh=True, allow_user_interaction=True)
+
+  def logout(self):
+    """Revokes the refresh token and deletes it from the cache.
+
+    Returns True if actually revoked a token.
+    """
+    revoked = False
+    with self._lock:
+      self._access_token = None
+      storage = self._get_storage()
+      credentials = storage.get()
+      if credentials:
+        credentials.revoke(httplib2.Http())
+        revoked = True
+      storage.delete()
+    return revoked
+
+  def has_cached_credentials(self):
+    """Returns True if long term credentials (refresh token) are in cache.
+
+    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, 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:
+      credentials = self._get_storage().get()
+      return credentials and not credentials.invalid
+
+  def get_access_token(self, force_refresh=False, allow_user_interaction=False):
+    """Returns AccessToken, refreshing it if necessary.
+
+    Args:
+      force_refresh: forcefully refresh access token even if it is not expired.
+      allow_user_interaction: True to enable blocking for user input if needed.
+
+    Raises:
+      AuthenticationError on error or if authentication flow was interrupted.
+      LoginRequiredError if user interaction is required, but
+          allow_user_interaction is False.
+    """
+    with self._lock:
+      if force_refresh:
+        self._access_token = self._create_access_token(allow_user_interaction)
+        return self._access_token
+
+      # 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 _needs_refresh(self._access_token):
+        # 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 _needs_refresh(self._access_token):
+          self._access_token = self._create_access_token(allow_user_interaction)
+
+      return self._access_token
+
+  def get_token_info(self):
+    """Returns a result of /oauth2/v2/tokeninfo call with token info."""
+    access_token = self.get_access_token()
+    resp, content = httplib2.Http().request(
+        uri='https://www.googleapis.com/oauth2/v2/tokeninfo?%s' % (
+            urllib.urlencode({'access_token': access_token.token})))
+    if resp.status == 200:
+      return json.loads(content)
+    raise AuthenticationError('Failed to fetch the token info: %r' % content)
+
+  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:
+       http: An instance of httplib2.Http.
+
+    Returns:
+       A modified instance of http that was passed in.
+    """
+    # Adapted from oauth2client.OAuth2Credentials.authorize.
+
+    request_orig = http.request
+
+    @functools.wraps(request_orig)
+    def new_request(
+        uri, method='GET', body=None, headers=None,
+        redirections=httplib2.DEFAULT_MAX_REDIRECTS,
+        connection_type=None):
+      headers = (headers or {}).copy()
+      headers['Authorizaton'] = 'Bearer %s' % self.get_access_token().token
+      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['Authorizaton'] = '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 _get_storage(self):
+    """Returns oauth2client.Storage with cached tokens."""
+    return multistore_file.get_credential_storage_custom_string_key(
+        OAUTH_TOKENS_CACHE, self._token_cache_key)
+
+  def _load_access_token(self):
+    """Returns cached AccessToken if it is not expired yet."""
+    credentials = self._get_storage().get()
+    if not credentials or credentials.invalid:
+      return None
+    if not credentials.access_token or credentials.access_token_expired:
+      return None
+    return AccessToken(credentials.access_token, credentials.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.
+
+    Raises:
+      AuthenticationError on error or if authentication flow was interrupted.
+      LoginRequiredError if user interaction is required, but
+          allow_user_interaction is False.
+    """
+    storage = self._get_storage()
+    credentials = None
+
+    # 3-legged flow with (perhaps cached) refresh token.
+    credentials = storage.get()
+    refreshed = False
+    if credentials and not credentials.invalid:
+      try:
+        credentials.refresh(httplib2.Http())
+        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:
+        raise LoginRequiredError(self._token_cache_key)
+      credentials = _run_oauth_dance(self._config)
+
+    logging.info(
+        'OAuth access_token refreshed. Expires in %s.',
+        credentials.token_expiry - datetime.datetime.utcnow())
+    credentials.set_store(storage)
+    storage.put(credentials)
+    return AccessToken(credentials.access_token, credentials.token_expiry)
+
+
+## Private functions.
+
+
+def _should_use_oauth2():
+  """Default value for use_oauth2 config option.
+
+  Used to selectively enable OAuth2 by default.
+  """
+  return os.path.exists(os.path.join(DEPOT_TOOLS_DIR, 'USE_OAUTH2'))
+
+
+def _is_headless():
+  """True if machine doesn't seem to have a display."""
+  return sys.platform == 'linux2' and not os.environ.get('DISPLAY')
+
+
+def _needs_refresh(access_token):
+  """True if AccessToken should be refreshed."""
+  if access_token.expires_at is not None:
+    # Allow 5 min of clock skew between client and backend.
+    now = datetime.datetime.utcnow() + datetime.timedelta(seconds=300)
+    return now >= access_token.expires_at
+  # Token without expiration time never expires.
+  return False
+
+
+def _run_oauth_dance(config):
+  """Perform full 3-legged OAuth2 flow with the browser.
+
+  Returns:
+    oauth2client.Credentials.
+
+  Raises:
+    AuthenticationError on errors.
+  """
+  flow = client.OAuth2WebServerFlow(
+      OAUTH_CLIENT_ID,
+      OAUTH_CLIENT_SECRET,
+      OAUTH_SCOPES,
+      approval_prompt='force')
+
+  use_local_webserver = config.use_local_webserver
+  port = config.webserver_port
+  if config.use_local_webserver:
+    success = False
+    try:
+      httpd = _ClientRedirectServer(('localhost', port), _ClientRedirectHandler)
+    except socket.error:
+      pass
+    else:
+      success = True
+    use_local_webserver = success
+    if not success:
+      print(
+        'Failed to start a local webserver listening on port %d.\n'
+        'Please check your firewall settings and locally running programs that '
+        'may be blocking or using those ports.\n\n'
+        'Falling back to --auth-no-local-webserver and continuing with '
+        'authentication.\n' % port)
+
+  if use_local_webserver:
+    oauth_callback = 'http://localhost:%s/' % port
+  else:
+    oauth_callback = client.OOB_CALLBACK_URN
+  flow.redirect_uri = oauth_callback
+  authorize_url = flow.step1_get_authorize_url()
+
+  if use_local_webserver:
+    webbrowser.open(authorize_url, new=1, autoraise=True)
+    print(
+      'Your browser has been opened to visit:\n\n'
+      '    %s\n\n'
+      'If your browser is on a different machine then exit and re-run this '
+      'application with the command-line parameter\n\n'
+      '  --auth-no-local-webserver\n' % authorize_url)
+  else:
+    print(
+      'Go to the following link in your browser:\n\n'
+      '    %s\n' % authorize_url)
+
+  try:
+    code = None
+    if use_local_webserver:
+      httpd.handle_request()
+      if 'error' in httpd.query_params:
+        raise AuthenticationError(
+            'Authentication request was rejected: %s' %
+            httpd.query_params['error'])
+      if 'code' not in httpd.query_params:
+        raise AuthenticationError(
+            'Failed to find "code" in the query parameters of the redirect.\n'
+            'Try running with --auth-no-local-webserver.')
+      code = httpd.query_params['code']
+    else:
+      code = raw_input('Enter verification code: ').strip()
+  except KeyboardInterrupt:
+    raise AuthenticationError('Authentication was canceled.')
+
+  try:
+    return flow.step2_exchange(code)
+  except client.FlowExchangeError as e:
+    raise AuthenticationError('Authentication has failed: %s' % e)
+
+
+class _ClientRedirectServer(BaseHTTPServer.HTTPServer):
+  """A server to handle OAuth 2.0 redirects back to localhost.
+
+  Waits for a single request and parses the query parameters
+  into query_params and then stops serving.
+  """
+  query_params = {}
+
+
+class _ClientRedirectHandler(BaseHTTPServer.BaseHTTPRequestHandler):
+  """A handler for OAuth 2.0 redirects back to localhost.
+
+  Waits for a single request and parses the query parameters
+  into the servers query_params and then stops serving.
+  """
+
+  def do_GET(self):
+    """Handle a GET request.
+
+    Parses the query parameters and prints a message
+    if the flow has completed. Note that we can't detect
+    if an error occurred.
+    """
+    self.send_response(200)
+    self.send_header('Content-type', 'text/html')
+    self.end_headers()
+    query = self.path.split('?', 1)[-1]
+    query = dict(urlparse.parse_qsl(query))
+    self.server.query_params = query
+    self.wfile.write('<html><head><title>Authentication Status</title></head>')
+    self.wfile.write('<body><p>The authentication flow has completed.</p>')
+    self.wfile.write('</body></html>')
+
+  def log_message(self, _format, *args):
+    """Do not log messages to stdout while running as command line program."""
diff --git a/depot-tools-auth b/depot-tools-auth
new file mode 100755
index 0000000..9233c92
--- /dev/null
+++ b/depot-tools-auth
@@ -0,0 +1,8 @@
+#!/usr/bin/env bash
+# Copyright 2015 The Chromium Authors. All rights reserved.
+# Use of this source code is governed by a BSD-style license that can be
+# found in the LICENSE file.
+
+base_dir=$(dirname "$0")
+
+PYTHONDONTWRITEBYTECODE=1 exec python "$base_dir/depot-tools-auth.py" "$@"
diff --git a/depot-tools-auth.bat b/depot-tools-auth.bat
new file mode 100644
index 0000000..fe13f93
--- /dev/null
+++ b/depot-tools-auth.bat
@@ -0,0 +1,11 @@
+@echo off

+:: Copyright 2015 The Chromium Authors. All rights reserved.

+:: Use of this source code is governed by a BSD-style license that can be

+:: found in the LICENSE file.

+setlocal

+

+:: This is required with cygwin only.

+PATH=%~dp0;%PATH%

+

+:: Defer control.

+%~dp0python "%~dp0\depot-tools-auth.py" %*

diff --git a/depot-tools-auth.py b/depot-tools-auth.py
new file mode 100755
index 0000000..3ebc239
--- /dev/null
+++ b/depot-tools-auth.py
@@ -0,0 +1,102 @@
+#!/usr/bin/env python
+# Copyright 2015 The Chromium Authors. All rights reserved.
+# Use of this source code is governed by a BSD-style license that can be
+# found in the LICENSE file.
+
+"""Manages cached OAuth2 tokens used by other depot_tools scripts.
+
+Usage:
+  depot-tools-auth login codereview.chromium.org
+  depot-tools-auth info codereview.chromium.org
+  depot-tools-auth logout codereview.chromium.org
+"""
+
+import logging
+import optparse
+import sys
+
+from third_party import colorama
+
+import auth
+import subcommand
+
+__version__ = '1.0'
+
+
+@subcommand.usage('<hostname>')
+def CMDlogin(parser, args):
+  """Performs interactive login and caches authentication token."""
+  # Forcefully relogin, revoking previous token.
+  hostname, authenticator = parser.parse_args(args)
+  authenticator.logout()
+  authenticator.login()
+  print_token_info(hostname, authenticator)
+  return 0
+
+
+@subcommand.usage('<hostname>')
+def CMDlogout(parser, args):
+  """Revokes cached authentication token and removes it from disk."""
+  _, authenticator = parser.parse_args(args)
+  done = authenticator.logout()
+  print 'Done.' if done else 'Already logged out.'
+  return 0
+
+
+@subcommand.usage('<hostname>')
+def CMDinfo(parser, args):
+  """Shows email associated with a cached authentication token."""
+  # If no token is cached, AuthenticationError will be caught in 'main'.
+  hostname, authenticator = parser.parse_args(args)
+  print_token_info(hostname, authenticator)
+  return 0
+
+
+def print_token_info(hostname, authenticator):
+  token_info = authenticator.get_token_info()
+  print 'Logged in to %s as %s.' % (hostname, token_info['email'])
+  print ''
+  print 'To login with a different email run:'
+  print '  depot-tools-auth login %s' % hostname
+  print 'To logout and purge the authentication token run:'
+  print '  depot-tools-auth logout %s' % hostname
+
+
+class OptionParser(optparse.OptionParser):
+  def __init__(self, *args, **kwargs):
+    optparse.OptionParser.__init__(
+        self, *args, prog='depot-tools-auth', version=__version__, **kwargs)
+    self.add_option(
+        '-v', '--verbose', action='count', default=0,
+        help='Use 2 times for more debugging info')
+    auth.add_auth_options(self, auth.make_auth_config(use_oauth2=True))
+
+  def parse_args(self, args=None, values=None):
+    """Parses options and returns (hostname, auth.Authenticator object)."""
+    options, args = optparse.OptionParser.parse_args(self, args, values)
+    levels = [logging.WARNING, logging.INFO, logging.DEBUG]
+    logging.basicConfig(level=levels[min(options.verbose, len(levels) - 1)])
+    auth_config = auth.extract_auth_config_from_options(options)
+    if len(args) != 1:
+      self.error('Expecting single argument (hostname).')
+    if not auth_config.use_oauth2:
+      self.error('This command is only usable with OAuth2 authentication')
+    return args[0], auth.get_authenticator_for_host(args[0], auth_config)
+
+
+def main(argv):
+  dispatcher = subcommand.CommandDispatcher(__name__)
+  try:
+    return dispatcher.execute(OptionParser(), argv)
+  except auth.AuthenticationError as e:
+    print >> sys.stderr, e
+    return 1
+
+
+if __name__ == '__main__':
+  colorama.init()
+  try:
+    sys.exit(main(sys.argv[1:]))
+  except KeyboardInterrupt:
+    sys.stderr.write('interrupted\n')
+    sys.exit(1)
diff --git a/git_cl.py b/git_cl.py
index 5484713..d4486e5 100755
--- a/git_cl.py
+++ b/git_cl.py
@@ -2062,6 +2062,15 @@
     base_branch = cl.GetCommonAncestorWithUpstream()
     args = [base_branch, 'HEAD']
 
+  # Make sure authenticated to Rietveld before running expensive hooks. It is
+  # a fast, best efforts check. Rietveld still can reject the authentication
+  # during the actual upload.
+  if not settings.GetIsGerrit() and auth_config.use_oauth2:
+    authenticator = auth.get_authenticator_for_host(
+        cl.GetRietveldServer(), auth_config)
+    if not authenticator.has_cached_credentials():
+      raise auth.LoginRequiredError(cl.GetRietveldServer())
+
   # Apply watchlists on upload.
   change = cl.GetChange(base_branch, None)
   watchlist = watchlists.Watchlists(change.RepositoryRoot())
@@ -3179,6 +3188,8 @@
   dispatcher = subcommand.CommandDispatcher(__name__)
   try:
     return dispatcher.execute(OptionParser(), argv)
+  except auth.AuthenticationError as e:
+    DieWithError(str(e))
   except urllib2.HTTPError, e:
     if e.code != 500:
       raise
diff --git a/oauth2.py b/oauth2.py
deleted file mode 100644
index 08f0abf..0000000
--- a/oauth2.py
+++ /dev/null
@@ -1,103 +0,0 @@
-# Copyright (c) 2015 The Chromium Authors. All rights reserved.
-# Use of this source code is governed by a BSD-style license that can be
-# found in the LICENSE file.
-
-"""OAuth2 related utilities and implementation for git cl commands."""
-
-import copy
-import logging
-import optparse
-import os
-
-from third_party.oauth2client import tools
-from third_party.oauth2client.file import Storage
-import third_party.oauth2client.client as oa2client
-
-
-REDIRECT_URI = 'urn:ietf:wg:oauth:2.0:oob'
-CLIENT_ID = ('174799409470-8k3b89iov4racu9jrf7if3k4591voig3'
-             '.apps.googleusercontent.com')
-CLIENT_SECRET = 'DddcCK1d6_ADwxqGDEGlsisy'
-SCOPE = 'email'
-
-
-def _fetch_storage(code_review_server):
-  storage_dir = os.path.expanduser(os.path.join('~', '.git_cl_credentials'))
-  if not os.path.isdir(storage_dir):
-    os.makedirs(storage_dir)
-  storage_path = os.path.join(storage_dir, code_review_server)
-  storage = Storage(storage_path)
-  return storage
-
-
-def _fetch_creds_from_storage(storage):
-  logging.debug('Fetching OAuth2 credentials from local storage ...')
-  credentials = storage.get()
-  if not credentials or credentials.invalid:
-    return None
-  if not credentials.access_token or credentials.access_token_expired:
-    return None
-  return credentials
-
-
-def add_oauth2_options(parser):
-  """Add OAuth2-related options."""
-  group = optparse.OptionGroup(parser, "OAuth2 options")
-  group.add_option(
-      '--auth-host-name',
-      default='localhost',
-      help='Host name to use when running a local web server '
-           'to handle redirects during OAuth authorization.'
-           'Default: localhost.'
-  )
-  group.add_option(
-      '--auth-host-port',
-      type=int,
-      action='append',
-      default=[8080, 8090],
-      help='Port to use when running a local web server to handle '
-           'redirects during OAuth authorization. '
-           'Repeat this option to specify a list of values.'
-           'Default: [8080, 8090].'
-  )
-  group.add_option(
-      '--noauth-local-webserver',
-      action='store_true',
-      default=False,
-      help='Run a local web server to handle redirects '
-           'during OAuth authorization.'
-           'Default: False.'
-  )
-  group.add_option(
-      '--no-cache',
-      action='store_true',
-      default=False,
-      help='Get fresh credentials from web server instead of using '
-           'the crendentials stored on a local storage file.'
-           'Default: False.'
-  )
-  parser.add_option_group(group)
-
-
-def get_oauth2_creds(options, code_review_server):
-  """Get OAuth2 credentials.
-
-  Args:
-    options: Command line options.
-    code_review_server: Code review server name, e.g., codereview.chromium.org.
-  """
-  storage = _fetch_storage(code_review_server)
-  creds = None
-  if not options.no_cache:
-    creds = _fetch_creds_from_storage(storage)
-  if creds is None:
-    logging.debug('Fetching OAuth2 credentials from web server...')
-    flow = oa2client.OAuth2WebServerFlow(
-        client_id=CLIENT_ID,
-        client_secret=CLIENT_SECRET,
-        scope=SCOPE,
-        redirect_uri=REDIRECT_URI)
-    flags = copy.deepcopy(options)
-    flags.logging_level = 'WARNING'
-    creds = tools.run_flow(flow, storage, flags)
-  return creds
diff --git a/tests/git_cl_test.py b/tests/git_cl_test.py
index b7d41f7..ed1d7a5 100755
--- a/tests/git_cl_test.py
+++ b/tests/git_cl_test.py
@@ -66,6 +66,13 @@
             "GERRIT_PORT: 29418\n")
 
 
+class AuthenticatorMock(object):
+  def __init__(self, *_args):
+    pass
+  def has_cached_credentials(self):
+    return True
+
+
 class TestGitCl(TestCase):
   def setUp(self):
     super(TestGitCl, self).setUp()
@@ -88,6 +95,7 @@
     self.mock(git_cl.rietveld, 'CachingRietveld', RietveldMock)
     self.mock(git_cl.upload, 'RealMain', self.fail)
     self.mock(git_cl.watchlists, 'Watchlists', WatchlistsMock)
+    self.mock(git_cl.auth, 'get_authenticator_for_host', AuthenticatorMock)
     # It's important to reset settings to not have inter-tests interference.
     git_cl.settings = None
 
@@ -161,13 +169,14 @@
       ((['git', 'config', 'branch.master.remote'],), 'origin'),
       ((['get_or_create_merge_base', 'master', 'master'],),
        'fake_ancestor_sha'),
+      ((['git', 'config', 'gerrit.host'],), ''),
+      ((['git', 'config', 'branch.master.rietveldissue'],), ''),
       ] + cls._git_sanity_checks('fake_ancestor_sha', 'master') + [
       ((['git', 'rev-parse', '--show-cdup'],), ''),
       ((['git', 'rev-parse', 'HEAD'],), '12345'),
       ((['git', 'diff', '--name-status', '--no-renames', '-r',
          'fake_ancestor_sha...', '.'],),
         'M\t.gitignore\n'),
-      ((['git', 'config', 'branch.master.rietveldissue'],), ''),
       ((['git', 'config', 'branch.master.rietveldpatchset'],),
        ''),
       ((['git', 'log', '--pretty=format:%s%n%n%b',
@@ -175,7 +184,6 @@
        'foo'),
       ((['git', 'config', 'user.email'],), 'me@example.com'),
       stat_call,
-      ((['git', 'config', 'gerrit.host'],), ''),
       ((['git', 'log', '--pretty=format:%s\n\n%b',
          'fake_ancestor_sha..HEAD'],),
        'desc\n'),
@@ -361,7 +369,6 @@
     return [
         'upload', '--assume_yes', '--server',
         'https://codereview.example.com',
-        '--no-oauth2', '--auth-host-port', '8090',
         '--message', description
     ] + args + [
         '--cc', 'joe@example.com',
@@ -546,6 +553,7 @@
         ((['git', 'config', 'branch.master.remote'],), 'origin'),
         ((['get_or_create_merge_base', 'master', 'master'],),
          'fake_ancestor_sha'),
+        ((['git', 'config', 'gerrit.host'],), 'gerrit.example.com'),
         ] + cls._git_sanity_checks('fake_ancestor_sha', 'master') + [
         ((['git', 'rev-parse', '--show-cdup'],), ''),
         ((['git', 'rev-parse', 'HEAD'],), '12345'),
@@ -569,8 +577,6 @@
   @staticmethod
   def _gerrit_upload_calls(description, reviewers, squash):
     calls = [
-        ((['git', 'config', 'gerrit.host'],),
-         'gerrit.example.com'),
         ((['git', 'log', '--pretty=format:%s\n\n%b',
            'fake_ancestor_sha..HEAD'],),
          description)
diff --git a/third_party/oauth2client/MODIFICATIONS.diff b/third_party/oauth2client/MODIFICATIONS.diff
index 2dfb0da..7490d91 100644
--- a/third_party/oauth2client/MODIFICATIONS.diff
+++ b/third_party/oauth2client/MODIFICATIONS.diff
@@ -14,7 +14,7 @@
  import time
  import urllib
  import urlparse
- 
+
 -from oauth2client import GOOGLE_AUTH_URI
 -from oauth2client import GOOGLE_REVOKE_URI
 -from oauth2client import GOOGLE_TOKEN_URI
@@ -25,7 +25,7 @@
 +from . import GOOGLE_TOKEN_URI
 +from . import util
 +from .anyjson import simplejson
- 
+
  HAS_OPENSSL = False
  HAS_CRYPTO = False
  try:
@@ -34,3 +34,33 @@
    HAS_CRYPTO = True
    if crypt.OpenSSLVerifier is not None:
      HAS_OPENSSL = True
+diff --git a/third_party/oauth2client/locked_file.py b/third_party/oauth2client/locked_file.py
+index 31514dc..858b702 100644
+--- a/third_party/oauth2client/locked_file.py
++++ b/third_party/oauth2client/locked_file.py
+@@ -35,7 +35,7 @@ import logging
+ import os
+ import time
+
+-from oauth2client import util
++from . import util
+
+ logger = logging.getLogger(__name__)
+
+diff --git a/third_party/oauth2client/multistore_file.py b/third_party/oauth2client/multistore_file.py
+index ce7a519..ea89027 100644
+--- a/third_party/oauth2client/multistore_file.py
++++ b/third_party/oauth2client/multistore_file.py
+@@ -50,9 +50,9 @@ import os
+ import threading
+
+ from anyjson import simplejson
+-from oauth2client.client import Storage as BaseStorage
+-from oauth2client.client import Credentials
+-from oauth2client import util
++from .client import Storage as BaseStorage
++from .client import Credentials
++from . import util
+ from locked_file import LockedFile
+
+ logger = logging.getLogger(__name__)
diff --git a/third_party/oauth2client/locked_file.py b/third_party/oauth2client/locked_file.py
index 31514dc..858b702 100644
--- a/third_party/oauth2client/locked_file.py
+++ b/third_party/oauth2client/locked_file.py
@@ -35,7 +35,7 @@
 import os
 import time
 
-from oauth2client import util
+from . import util
 
 logger = logging.getLogger(__name__)
 
diff --git a/third_party/oauth2client/multistore_file.py b/third_party/oauth2client/multistore_file.py
index ce7a519..ea89027 100644
--- a/third_party/oauth2client/multistore_file.py
+++ b/third_party/oauth2client/multistore_file.py
@@ -50,9 +50,9 @@
 import threading
 
 from anyjson import simplejson
-from oauth2client.client import Storage as BaseStorage
-from oauth2client.client import Credentials
-from oauth2client import util
+from .client import Storage as BaseStorage
+from .client import Credentials
+from . import util
 from locked_file import LockedFile
 
 logger = logging.getLogger(__name__)
diff --git a/third_party/upload.py b/third_party/upload.py
index ca8c7b3..142789f 100755
--- a/third_party/upload.py
+++ b/third_party/upload.py
@@ -34,7 +34,6 @@
 # This code is derived from appcfg.py in the App Engine SDK (open source),
 # and from ASPN recipe #146306.
 
-import BaseHTTPServer
 import ConfigParser
 import cookielib
 import errno
@@ -52,7 +51,6 @@
 import urllib
 import urllib2
 import urlparse
-import webbrowser
 
 from multiprocessing.pool import ThreadPool
 
@@ -126,48 +124,6 @@
   VCS_ABBREVIATIONS.update((alias, vcs['name']) for alias in vcs['aliases'])
 
 
-# OAuth 2.0-Related Constants
-LOCALHOST_IP = '127.0.0.1'
-DEFAULT_OAUTH2_PORT = 8001
-ACCESS_TOKEN_PARAM = 'access_token'
-ERROR_PARAM = 'error'
-OAUTH_DEFAULT_ERROR_MESSAGE = 'OAuth 2.0 error occurred.'
-OAUTH_PATH = '/get-access-token'
-OAUTH_PATH_PORT_TEMPLATE = OAUTH_PATH + '?port=%(port)d'
-AUTH_HANDLER_RESPONSE = """\
-<html>
-  <head>
-    <title>Authentication Status</title>
-    <script>
-    window.onload = function() {
-      window.close();
-    }
-    </script>
-  </head>
-  <body>
-    <p>The authentication flow has completed.</p>
-  </body>
-</html>
-"""
-# Borrowed from google-api-python-client
-OPEN_LOCAL_MESSAGE_TEMPLATE = """\
-Your browser has been opened to visit:
-
-    %s
-
-If your browser is on a different machine then exit and re-run
-upload.py with the command-line parameter
-
-  --no_oauth2_webbrowser
-"""
-NO_OPEN_LOCAL_MESSAGE_TEMPLATE = """\
-Go to the following link in your browser:
-
-    %s
-
-and copy the access token.
-"""
-
 # The result of parsing Subversion's [auto-props] setting.
 svn_auto_props_map = None
 
@@ -361,7 +317,7 @@
                               response.headers, response.fp)
     self.authenticated = True
 
-  def _Authenticate(self):
+  def _Authenticate(self, force_refresh):
     """Authenticates the user.
 
     The authentication process works as follows:
@@ -466,10 +422,11 @@
     # TODO: Don't require authentication.  Let the server say
     # whether it is necessary.
     if not self.authenticated and self.auth_function:
-      self._Authenticate()
+      self._Authenticate(force_refresh=False)
 
     old_timeout = socket.getdefaulttimeout()
     socket.setdefaulttimeout(timeout)
+    auth_attempted = False
     try:
       tries = 0
       while True:
@@ -491,10 +448,16 @@
         except urllib2.HTTPError, e:
           if tries > 3:
             raise
-          elif e.code == 401 or e.code == 302:
+          elif e.code in (302, 401, 403):
             if not self.auth_function:
               raise
-            self._Authenticate()
+            # Already tried force refresh, didn't help -> give up with error.
+            if auth_attempted:
+              raise auth.AuthenticationError(
+                  'Access to %s is denied (server returned HTTP %d).'
+                  % (self.host, e.code))
+            self._Authenticate(force_refresh=True)
+            auth_attempted = True
           elif e.code == 301:
             # Handle permanent redirect manually.
             url = e.info()["location"]
@@ -513,15 +476,22 @@
 class HttpRpcServer(AbstractRpcServer):
   """Provides a simplified RPC-style interface for HTTP requests."""
 
-  def _Authenticate(self):
+  def _Authenticate(self, force_refresh):
     """Save the cookie jar after authentication."""
-    if isinstance(self.auth_function, OAuth2Creds):
-      access_token = self.auth_function()
-      if access_token is not None:
-        self.extra_headers['Authorization'] = 'OAuth %s' % (access_token,)
-        self.authenticated = True
+    if isinstance(self.auth_function, auth.Authenticator):
+      try:
+        access_token = self.auth_function.get_access_token(force_refresh)
+      except auth.LoginRequiredError:
+        # Attempt to make unauthenticated request first if there's no cached
+        # credentials. HttpRpcServer calls __Authenticate(force_refresh=True)
+        # again if unauthenticated request doesn't work.
+        if not force_refresh:
+          return
+        raise
+      self.extra_headers['Authorization'] = 'Bearer %s' % (
+          access_token.token,)
     else:
-      super(HttpRpcServer, self)._Authenticate()
+      super(HttpRpcServer, self)._Authenticate(force_refresh)
       if self.save_cookies:
         StatusUpdate("Saving authentication cookies to %s" % self.cookie_file)
         self.cookie_jar.save()
@@ -714,150 +684,6 @@
                  help=("Perforce user"))
 
 
-# OAuth 2.0 Methods and Helpers
-class ClientRedirectServer(BaseHTTPServer.HTTPServer):
-  """A server for redirects back to localhost from the associated server.
-
-  Waits for a single request and parses the query parameters for an access token
-  or an error and then stops serving.
-  """
-  access_token = None
-  error = None
-
-
-class ClientRedirectHandler(BaseHTTPServer.BaseHTTPRequestHandler):
-  """A handler for redirects back to localhost from the associated server.
-
-  Waits for a single request and parses the query parameters into the server's
-  access_token or error and then stops serving.
-  """
-
-  def SetResponseValue(self):
-    """Stores the access token or error from the request on the server.
-
-    Will only do this if exactly one query parameter was passed in to the
-    request and that query parameter used 'access_token' or 'error' as the key.
-    """
-    query_string = urlparse.urlparse(self.path).query
-    query_params = urlparse.parse_qs(query_string)
-
-    if len(query_params) == 1:
-      if query_params.has_key(ACCESS_TOKEN_PARAM):
-        access_token_list = query_params[ACCESS_TOKEN_PARAM]
-        if len(access_token_list) == 1:
-          self.server.access_token = access_token_list[0]
-      else:
-        error_list = query_params.get(ERROR_PARAM, [])
-        if len(error_list) == 1:
-          self.server.error = error_list[0]
-
-  def do_GET(self):
-    """Handle a GET request.
-
-    Parses and saves the query parameters and prints a message that the server
-    has completed its lone task (handling a redirect).
-
-    Note that we can't detect if an error occurred.
-    """
-    self.send_response(200)
-    self.send_header('Content-type', 'text/html')
-    self.end_headers()
-    self.SetResponseValue()
-    self.wfile.write(AUTH_HANDLER_RESPONSE)
-
-  def log_message(self, format, *args):
-    """Do not log messages to stdout while running as command line program."""
-    pass
-
-
-def OpenOAuth2ConsentPage(server=DEFAULT_REVIEW_SERVER,
-                          port=DEFAULT_OAUTH2_PORT):
-  """Opens the OAuth 2.0 consent page or prints instructions how to.
-
-  Uses the webbrowser module to open the OAuth server side page in a browser.
-
-  Args:
-    server: String containing the review server URL. Defaults to
-      DEFAULT_REVIEW_SERVER.
-    port: Integer, the port where the localhost server receiving the redirect
-      is serving. Defaults to DEFAULT_OAUTH2_PORT.
-
-  Returns:
-    A boolean indicating whether the page opened successfully.
-  """
-  path = OAUTH_PATH_PORT_TEMPLATE % {'port': port}
-  parsed_url = urlparse.urlparse(server)
-  scheme = parsed_url[0] or 'https'
-  if scheme != 'https':
-    ErrorExit('Using OAuth requires a review server with SSL enabled.')
-  # If no scheme was given on command line the server address ends up in
-  # parsed_url.path otherwise in netloc.
-  host = parsed_url[1] or parsed_url[2]
-  page = '%s://%s%s' % (scheme, host, path)
-  page_opened = webbrowser.open(page, new=1, autoraise=True)
-  if page_opened:
-    print OPEN_LOCAL_MESSAGE_TEMPLATE % (page,)
-  return page_opened
-
-
-def WaitForAccessToken(port=DEFAULT_OAUTH2_PORT):
-  """Spins up a simple HTTP Server to handle a single request.
-
-  Intended to handle a single redirect from the production server after the
-  user authenticated via OAuth 2.0 with the server.
-
-  Args:
-    port: Integer, the port where the localhost server receiving the redirect
-      is serving. Defaults to DEFAULT_OAUTH2_PORT.
-
-  Returns:
-    The access token passed to the localhost server, or None if no access token
-      was passed.
-  """
-  httpd = ClientRedirectServer((LOCALHOST_IP, port), ClientRedirectHandler)
-  # Wait to serve just one request before deferring control back
-  # to the caller of wait_for_refresh_token
-  httpd.handle_request()
-  if httpd.access_token is None:
-    ErrorExit(httpd.error or OAUTH_DEFAULT_ERROR_MESSAGE)
-  return httpd.access_token
-
-
-def GetAccessToken(server=DEFAULT_REVIEW_SERVER, port=DEFAULT_OAUTH2_PORT,
-                   open_local_webbrowser=True):
-  """Gets an Access Token for the current user.
-
-  Args:
-    server: String containing the review server URL. Defaults to
-      DEFAULT_REVIEW_SERVER.
-    port: Integer, the port where the localhost server receiving the redirect
-      is serving. Defaults to DEFAULT_OAUTH2_PORT.
-    open_local_webbrowser: Boolean, defaults to True. If set, opens a page in
-      the user's browser.
-
-  Returns:
-    A string access token that was sent to the local server. If the serving page
-      via WaitForAccessToken does not receive an access token, this method
-      returns None.
-  """
-  access_token = None
-  if open_local_webbrowser:
-    page_opened = OpenOAuth2ConsentPage(server=server, port=port)
-    if page_opened:
-      try:
-        access_token = WaitForAccessToken(port=port)
-      except socket.error, e:
-        print 'Can\'t start local webserver. Socket Error: %s\n' % (e.strerror,)
-
-  if access_token is None:
-    # TODO(dhermes): Offer to add to clipboard using xsel, xclip, pbcopy, etc.
-    page = 'https://%s%s' % (server, OAUTH_PATH)
-    print NO_OPEN_LOCAL_MESSAGE_TEMPLATE % (page,)
-    access_token = raw_input('Enter access token: ').strip()
-
-  return access_token
-
-
 class KeyringCreds(object):
   def __init__(self, server, host, email):
     self.server = server
@@ -903,20 +729,6 @@
     return (email, password)
 
 
-class OAuth2Creds(object):
-  """Simple object to hold server and port to be passed to GetAccessToken."""
-
-  def __init__(self, server, port, open_local_webbrowser=True):
-    self.server = server
-    self.port = port
-    self.open_local_webbrowser = open_local_webbrowser
-
-  def __call__(self):
-    """Uses stored server and port to retrieve OAuth 2.0 access token."""
-    return GetAccessToken(server=self.server, port=self.port,
-                          open_local_webbrowser=self.open_local_webbrowser)
-
-
 def GetRpcServer(server, auth_config=None, email=None):
   """Returns an instance of an AbstractRpcServer.
 
@@ -934,9 +746,6 @@
   if email == '' or not auth_config:
     return HttpRpcServer(server, None)
 
-  if auth_config.use_oauth2:
-    raise NotImplementedError('See https://crbug.com/356813')
-
   # If this is the dev_appserver, use fake authentication.
   host = server.lower()
   if re.match(r'(http://)?localhost([:/]|$)', host):
@@ -954,9 +763,14 @@
     server.authenticated = True
     return server
 
+  if auth_config.use_oauth2:
+    auth_func = auth.get_authenticator_for_host(server, auth_config)
+  else:
+    auth_func = KeyringCreds(server, host, email).GetUserCredentials
+
   return HttpRpcServer(
       server,
-      KeyringCreds(server, host, email).GetUserCredentials,
+      auth_func,
       save_cookies=auth_config.save_cookies,
       account_type=AUTH_ACCOUNT_TYPE)
 
@@ -2713,6 +2527,9 @@
     print
     StatusUpdate("Interrupted.")
     sys.exit(1)
+  except auth.AuthenticationError as e:
+    print >> sys.stderr, e
+    sys.exit(1)
 
 
 if __name__ == "__main__":