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>
changes/98/1854898/12
Edward Lemur 6 years ago committed by Commit Bot
parent 0cb3e4bf7d
commit 55e5853e5c

@ -21,7 +21,6 @@ import urlparse
import subprocess2 import subprocess2
from third_party import httplib2 from third_party import httplib2
from third_party.oauth2client import client
# depot_tools/. # depot_tools/.
@ -55,8 +54,8 @@ class AccessToken(collections.namedtuple('AccessToken', [
"""True if this AccessToken should be refreshed.""" """True if this AccessToken should be refreshed."""
if self.expires_at is not None: if self.expires_at is not None:
now = now or datetime.datetime.utcnow() now = now or datetime.datetime.utcnow()
# Allow 3 min of clock skew between client and backend. # Allow 30s of clock skew between client and backend.
now += datetime.timedelta(seconds=180) now += datetime.timedelta(seconds=30)
return now >= self.expires_at return now >= self.expires_at
# Token without expiration time never expires. # Token without expiration time never expires.
return False return False
@ -100,6 +99,8 @@ def has_luci_context_local_auth():
return bool(params.default_account_id) 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): def get_luci_context_access_token(scopes=OAUTH_SCOPE_EMAIL):
"""Returns a valid AccessToken from the local LUCI context auth server. """Returns a valid AccessToken from the local LUCI context auth server.
@ -291,18 +292,18 @@ def add_auth_options(parser, default_config=None):
help='Do not save authentication cookies to local disk.') help='Do not save authentication cookies to local disk.')
# OAuth2 related options. # OAuth2 related options.
# TODO(crbug.com/1001756): Remove. No longer supported.
parser.auth_group.add_option( parser.auth_group.add_option(
'--auth-no-local-webserver', '--auth-no-local-webserver',
action='store_false', action='store_false',
dest='use_local_webserver', dest='use_local_webserver',
default=default_config.use_local_webserver, default=default_config.use_local_webserver,
help='Do not run a local web server when performing OAuth2 login flow.') help='DEPRECATED. Do not use')
parser.auth_group.add_option( parser.auth_group.add_option(
'--auth-host-port', '--auth-host-port',
type=int, type=int,
default=default_config.webserver_port, default=default_config.webserver_port,
help='Port a local web server should listen on. Used only if ' help='DEPRECATED. Do not use')
'--auth-no-local-webserver is not set. [default: %default]')
parser.auth_group.add_option( parser.auth_group.add_option(
'--auth-refresh-token-json', '--auth-refresh-token-json',
help='DEPRECATED. Do not use') help='DEPRECATED. Do not use')
@ -372,27 +373,25 @@ class Authenticator(object):
logging.debug('Using auth config %r', config) logging.debug('Using auth config %r', config)
def has_cached_credentials(self): def has_cached_credentials(self):
"""Returns True if long term credentials (refresh token) are in cache. """Returns True if credentials can be obtained.
Doesn't make network calls.
If returns False, get_access_token() later will ask for interactive login by If returns False, get_access_token() later will probably ask for interactive
raising LoginRequiredError. login by raising LoginRequiredError, unless local auth in configured.
If returns True, most probably get_access_token() won't ask for interactive 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 login, unless an external token is provided that has been revoked.
revoked and there's no way to figure this out without actually trying to use
it.
""" """
with self._lock: with self._lock:
return bool(self._get_cached_credentials()) return bool(self._get_luci_auth_token())
def get_access_token(self, force_refresh=False, allow_user_interaction=False, def get_access_token(self, force_refresh=False, allow_user_interaction=False,
use_local_auth=True): use_local_auth=True):
"""Returns AccessToken, refreshing it if necessary. """Returns AccessToken, refreshing it if necessary.
Args: Args:
force_refresh: forcefully refresh access token even if it is not expired. TODO(crbug.com/1001756): Remove. luci-auth doesn't support
force-refreshing tokens.
force_refresh: Ignored,
allow_user_interaction: True to enable blocking for user input if needed. allow_user_interaction: True to enable blocking for user input if needed.
use_local_auth: default to local auth if needed. use_local_auth: default to local auth if needed.
@ -401,53 +400,41 @@ class Authenticator(object):
LoginRequiredError if user interaction is required, but LoginRequiredError if user interaction is required, but
allow_user_interaction is False. allow_user_interaction is False.
""" """
def get_loc_auth_tkn(): with self._lock:
exi = sys.exc_info() if self._access_token and not self._access_token.needs_refresh():
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 return self._access_token
except LuciContextAuthError:
logging.exception('Failed to use local auth')
raise exi[0], exi[1], exi[2]
with self._lock: # Token expired or missing. Maybe some other process already updated it,
if force_refresh: # reload from the cache.
logging.debug('Forcing access token refresh') 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:
try: try:
self._access_token = self._create_access_token(allow_user_interaction) self._access_token = get_luci_context_access_token()
return self._access_token except LuciContextAuthError:
except LoginRequiredError: logging.exception('Failed to use local auth')
return get_loc_auth_tkn() if self._access_token and not self._access_token.needs_refresh():
return self._access_token
# Load from on-disk cache on a first access.
if not self._access_token: # Give up.
self._access_token = self._load_access_token() logging.error('Failed to create access token')
raise LoginRequiredError(self._scopes)
# 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): def authorize(self, http):
"""Monkey patches authentication logic of httplib2.Http instance. """Monkey patches authentication logic of httplib2.Http instance.
The modified http.request method will add authentication headers to each 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. request.
Args: Args:
@ -457,7 +444,6 @@ class Authenticator(object):
A modified instance of http that was passed in. A modified instance of http that was passed in.
""" """
# Adapted from oauth2client.OAuth2Credentials.authorize. # Adapted from oauth2client.OAuth2Credentials.authorize.
request_orig = http.request request_orig = http.request
@functools.wraps(request_orig) @functools.wraps(request_orig)
@ -467,92 +453,37 @@ class Authenticator(object):
connection_type=None): connection_type=None):
headers = (headers or {}).copy() headers = (headers or {}).copy()
headers['Authorization'] = 'Bearer %s' % self.get_access_token().token headers['Authorization'] = 'Bearer %s' % self.get_access_token().token
resp, content = request_orig( return request_orig(
uri, method, body, headers, redirections, connection_type) 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 http.request = new_request
return http return http
## Private methods. ## Private methods.
def _get_cached_credentials(self): def _run_luci_auth_login(self):
"""Returns oauth2client.Credentials loaded from luci-auth.""" """Run luci-auth login.
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: Returns:
AccessToken. AccessToken with credentials.
Raises:
AuthenticationError on error or if authentication flow was interrupted.
LoginRequiredError if user interaction is required, but
allow_user_interaction is False.
""" """
logging.debug( logging.debug('Running luci-auth login')
'Making new access token (allow_user_interaction=%r)', subprocess2.check_call(['luci-auth', 'login', '-scopes', self._scopes])
allow_user_interaction) return self._get_luci_auth_token()
credentials = self._get_cached_credentials()
def _get_luci_auth_token(self):
# 3-legged flow with (perhaps cached) refresh token. logging.debug('Running luci-auth token')
refreshed = False try:
if credentials and not credentials.invalid: out, err = subprocess2.check_call_out(
try: ['luci-auth', 'token', '-scopes', self._scopes, '-json-output', '-'],
logging.debug('Attempting to refresh access_token') stdout=subprocess2.PIPE, stderr=subprocess2.PIPE)
credentials.refresh(httplib2.Http()) logging.debug('luci-auth token stderr:\n%s', err)
_log_credentials_info('refreshed token', credentials) token_info = json.loads(out)
refreshed = True return AccessToken(
except client.Error as err: token_info['token'],
logging.warning( datetime.datetime.utcfromtimestamp(token_info['expiry']))
'OAuth error during access token refresh (%s). ' except subprocess2.CalledProcessError:
'Attempting a full authentication flow.', err) return None
# 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. ## Private functions.
@ -561,44 +492,3 @@ class Authenticator(object):
def _is_headless(): def _is_headless():
"""True if machine doesn't seem to have a display.""" """True if machine doesn't seem to have a display."""
return sys.platform == 'linux2' and not os.environ.get('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)

@ -5,7 +5,7 @@
"""Unit Tests for auth.py""" """Unit Tests for auth.py"""
import __builtin__ import contextlib
import datetime import datetime
import json import json
import logging import logging
@ -16,37 +16,35 @@ import time
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
from testing_support import auto_stub
from third_party import httplib2
from third_party import mock from third_party import mock
from third_party import httplib2
import auth import auth
class TestLuciContext(auto_stub.TestCase): def _mockLocalAuth(account_id, secret, rpc_port):
def setUp(self): mock_luci_context = {
auth._get_luci_context_local_auth_params.clear_cache()
def _mock_local_auth(self, account_id, secret, rpc_port):
self.mock(os, 'environ', {'LUCI_CONTEXT': 'default/test/path'})
self.mock(auth, '_load_luci_context', mock.Mock())
auth._load_luci_context.return_value = {
'local_auth': { 'local_auth': {
'default_account_id': account_id, 'default_account_id': account_id,
'secret': secret, 'secret': secret,
'rpc_port': rpc_port, 'rpc_port': rpc_port,
} }
} }
mock.patch('auth._load_luci_context', return_value=mock_luci_context).start()
mock.patch('os.environ', {'LUCI_CONTEXT': 'default/test/path'}).start()
def _mockResponse(status, content):
mock_response = (mock.Mock(status=status), content)
mock.patch('auth.httplib2.Http.request', return_value=mock_response).start()
def _mock_loc_server_resp(self, status, content):
mock_resp = mock.Mock() class TestLuciContext(unittest.TestCase):
mock_resp.status = status def setUp(self):
self.mock(httplib2.Http, 'request', mock.Mock()) auth._get_luci_context_local_auth_params.clear_cache()
httplib2.Http.request.return_value = (mock_resp, content)
def test_all_good(self): def test_all_good(self):
self._mock_local_auth('account', 'secret', 8080) _mockLocalAuth('account', 'secret', 8080)
self.assertTrue(auth.has_luci_context_local_auth()) self.assertTrue(auth.has_luci_context_local_auth())
expiry_time = datetime.datetime.min + datetime.timedelta(hours=1) expiry_time = datetime.datetime.min + datetime.timedelta(hours=1)
@ -57,18 +55,18 @@ class TestLuciContext(auto_stub.TestCase):
'expiry': (expiry_time 'expiry': (expiry_time
- datetime.datetime.utcfromtimestamp(0)).total_seconds(), - datetime.datetime.utcfromtimestamp(0)).total_seconds(),
} }
self._mock_loc_server_resp(200, json.dumps(resp_content)) _mockResponse(200, json.dumps(resp_content))
params = auth._get_luci_context_local_auth_params() params = auth._get_luci_context_local_auth_params()
token = auth._get_luci_context_access_token(params, datetime.datetime.min) token = auth._get_luci_context_access_token(params, datetime.datetime.min)
self.assertEqual(token.token, 'token') self.assertEqual(token.token, 'token')
def test_no_account_id(self): def test_no_account_id(self):
self._mock_local_auth(None, 'secret', 8080) _mockLocalAuth(None, 'secret', 8080)
self.assertFalse(auth.has_luci_context_local_auth()) self.assertFalse(auth.has_luci_context_local_auth())
self.assertIsNone(auth.get_luci_context_access_token()) self.assertIsNone(auth.get_luci_context_access_token())
def test_incorrect_port_format(self): def test_incorrect_port_format(self):
self._mock_local_auth('account', 'secret', 'port') _mockLocalAuth('account', 'secret', 'port')
self.assertFalse(auth.has_luci_context_local_auth()) self.assertFalse(auth.has_luci_context_local_auth())
with self.assertRaises(auth.LuciContextAuthError): with self.assertRaises(auth.LuciContextAuthError):
auth.get_luci_context_access_token() auth.get_luci_context_access_token()
@ -81,7 +79,7 @@ class TestLuciContext(auto_stub.TestCase):
'access_token': 'token', 'access_token': 'token',
'expiry': 1, 'expiry': 1,
} }
self._mock_loc_server_resp(200, json.dumps(resp_content)) _mockResponse(200, json.dumps(resp_content))
with self.assertRaises(auth.LuciContextAuthError): with self.assertRaises(auth.LuciContextAuthError):
auth._get_luci_context_access_token( auth._get_luci_context_access_token(
params, datetime.datetime.utcfromtimestamp(1)) params, datetime.datetime.utcfromtimestamp(1))
@ -94,13 +92,13 @@ class TestLuciContext(auto_stub.TestCase):
'access_token': 'token', 'access_token': 'token',
'expiry': 'dead', 'expiry': 'dead',
} }
self._mock_loc_server_resp(200, json.dumps(resp_content)) _mockResponse(200, json.dumps(resp_content))
with self.assertRaises(auth.LuciContextAuthError): with self.assertRaises(auth.LuciContextAuthError):
auth._get_luci_context_access_token(params, datetime.datetime.min) auth._get_luci_context_access_token(params, datetime.datetime.min)
def test_incorrect_response_content_format(self): def test_incorrect_response_content_format(self):
params = auth._LuciContextLocalAuthParams('account', 'secret', 8080) params = auth._LuciContextLocalAuthParams('account', 'secret', 8080)
self._mock_loc_server_resp(200, '5') _mockResponse(200, '5')
with self.assertRaises(auth.LuciContextAuthError): with self.assertRaises(auth.LuciContextAuthError):
auth._get_luci_context_access_token(params, datetime.datetime.min) auth._get_luci_context_access_token(params, datetime.datetime.min)

Loading…
Cancel
Save