blob: 11a2113b4ebcc39542788ec646e59dfcd881e9bc [file] [log] [blame]
vadimsh@chromium.orgeed4df32015-04-10 21:30:20 +00001# Copyright 2015 The Chromium Authors. All rights reserved.
vadimsh@chromium.orgcf6a5d22015-04-09 22:02:00 +00002# Use of this source code is governed by a BSD-style license that can be
3# found in the LICENSE file.
4
vadimsh@chromium.orgeed4df32015-04-10 21:30:20 +00005"""Google OAuth2 related functions."""
vadimsh@chromium.orgcf6a5d22015-04-09 22:02:00 +00006
vadimsh@chromium.orgeed4df32015-04-10 21:30:20 +00007import BaseHTTPServer
vadimsh@chromium.orgcf6a5d22015-04-09 22:02:00 +00008import collections
vadimsh@chromium.orgeed4df32015-04-10 21:30:20 +00009import datetime
10import functools
vadimsh@chromium.org24daf9e2015-04-17 02:42:44 +000011import hashlib
vadimsh@chromium.orgeed4df32015-04-10 21:30:20 +000012import json
13import logging
vadimsh@chromium.orgcf6a5d22015-04-09 22:02:00 +000014import optparse
vadimsh@chromium.orgeed4df32015-04-10 21:30:20 +000015import os
16import socket
17import sys
18import threading
Mun Yong Jangacc8e3e2017-11-22 10:49:56 -080019import time
vadimsh@chromium.orgeed4df32015-04-10 21:30:20 +000020import urllib
21import urlparse
22import webbrowser
23
24from third_party import httplib2
25from third_party.oauth2client import client
26from third_party.oauth2client import multistore_file
27
28
29# depot_tools/.
30DEPOT_TOOLS_DIR = os.path.dirname(os.path.abspath(__file__))
31
32
33# Google OAuth2 clients always have a secret, even if the client is an installed
34# application/utility such as this. Of course, in such cases the "secret" is
35# actually publicly known; security depends entirely on the secrecy of refresh
36# tokens, which effectively become bearer tokens. An attacker can impersonate
37# service's identity in OAuth2 flow. But that's generally fine as long as a list
38# of allowed redirect_uri's associated with client_id is limited to 'localhost'
39# or 'urn:ietf:wg:oauth:2.0:oob'. In that case attacker needs some process
40# running on user's machine to successfully complete the flow and grab refresh
41# token. When you have a malicious code running on your machine, you're screwed
42# anyway.
43# This particular set is managed by API Console project "chrome-infra-auth".
44OAUTH_CLIENT_ID = (
45 '446450136466-2hr92jrq8e6i4tnsa56b52vacp7t3936.apps.googleusercontent.com')
46OAUTH_CLIENT_SECRET = 'uBfbay2KCy9t4QveJ-dOqHtp'
47
48# List of space separated OAuth scopes for generated tokens. GAE apps usually
49# use userinfo.email scope for authentication.
50OAUTH_SCOPES = 'https://www.googleapis.com/auth/userinfo.email'
51
seanmccullough@chromium.org3e4a5812015-06-11 17:48:47 +000052# Additional OAuth scopes.
53ADDITIONAL_SCOPES = {
54 'code.google.com': 'https://www.googleapis.com/auth/projecthosting',
55}
56
vadimsh@chromium.org148f76f2015-04-21 01:44:13 +000057# Path to a file with cached OAuth2 credentials used by default relative to the
58# home dir (see _get_token_cache_path). It should be a safe location accessible
59# only to a current user: knowing content of this file is roughly equivalent to
60# knowing account password. Single file can hold multiple independent tokens
61# identified by token_cache_key (see Authenticator).
62OAUTH_TOKENS_CACHE = '.depot_tools_oauth2_tokens'
vadimsh@chromium.orgcf6a5d22015-04-09 22:02:00 +000063
64
65# Authentication configuration extracted from command line options.
66# See doc string for 'make_auth_config' for meaning of fields.
67AuthConfig = collections.namedtuple('AuthConfig', [
68 'use_oauth2', # deprecated, will be always True
69 'save_cookies', # deprecated, will be removed
70 'use_local_webserver',
71 'webserver_port',
vadimsh@chromium.org24daf9e2015-04-17 02:42:44 +000072 'refresh_token_json',
vadimsh@chromium.orgcf6a5d22015-04-09 22:02:00 +000073])
74
75
vadimsh@chromium.orgeed4df32015-04-10 21:30:20 +000076# OAuth access token with its expiration time (UTC datetime or None if unknown).
77AccessToken = collections.namedtuple('AccessToken', [
78 'token',
79 'expires_at',
80])
81
82
vadimsh@chromium.org24daf9e2015-04-17 02:42:44 +000083# Refresh token passed via --auth-refresh-token-json.
84RefreshToken = collections.namedtuple('RefreshToken', [
85 'client_id',
86 'client_secret',
87 'refresh_token',
88])
89
90
vadimsh@chromium.orgeed4df32015-04-10 21:30:20 +000091class AuthenticationError(Exception):
92 """Raised on errors related to authentication."""
93
94
95class LoginRequiredError(AuthenticationError):
96 """Interaction with the user is required to authenticate."""
97
98 def __init__(self, token_cache_key):
99 # HACK(vadimsh): It is assumed here that the token cache key is a hostname.
100 msg = (
101 'You are not logged in. Please login first by running:\n'
102 ' depot-tools-auth login %s' % token_cache_key)
103 super(LoginRequiredError, self).__init__(msg)
104
105
Mun Yong Jangacc8e3e2017-11-22 10:49:56 -0800106class LuciContextAuthError(Exception):
107 """Raised on errors related to unsuccessful attempts to load LUCI_CONTEXT"""
108
109
110def get_luci_context_access_token():
111 """Returns a valid AccessToken from the local LUCI context auth server.
112
113 Adapted from
114 https://chromium.googlesource.com/infra/luci/luci-py/+/master/client/libs/luci_context/luci_context.py
115 See the link above for more details.
116
117 Returns:
118 AccessToken if LUCI_CONTEXT is present and attempt to load it is successful.
119 None if LUCI_CONTEXT is absent.
120
121 Raises:
122 LuciContextAuthError if the attempt to load LUCI_CONTEXT
123 and request its access token is unsuccessful.
124 """
125 return _get_luci_context_access_token(os.environ, datetime.datetime.utcnow())
126
127
128def _get_luci_context_access_token(env, now):
129 ctx_path = env.get('LUCI_CONTEXT')
130 if not ctx_path:
131 return None
132 ctx_path = ctx_path.decode(sys.getfilesystemencoding())
133 logging.debug('Loading LUCI_CONTEXT: %r', ctx_path)
134
135 def authErr(msg, *args):
136 error_msg = msg % args
137 ex = sys.exc_info()[1]
138 if not ex:
139 logging.error(error_msg)
140 raise LuciContextAuthError(error_msg)
141 logging.exception(error_msg)
142 raise LuciContextAuthError('%s: %s' % (error_msg, ex))
143
144 try:
145 loaded = _load_luci_context(ctx_path)
146 except (OSError, IOError, ValueError):
147 authErr('Failed to open, read or decode LUCI_CONTEXT')
148 try:
149 local_auth = loaded.get('local_auth')
150 except AttributeError:
151 authErr('LUCI_CONTEXT not in proper format')
152 # failed to grab local_auth from LUCI context
153 if not local_auth:
154 logging.debug('local_auth: no local auth found')
155 return None
156 try:
157 account_id = local_auth.get('default_account_id')
158 secret = local_auth.get('secret')
159 rpc_port = int(local_auth.get('rpc_port'))
160 except (AttributeError, ValueError):
161 authErr('local_auth: unexpected local auth format')
162
163 if not secret:
164 authErr('local_auth: no secret returned')
165 # if account_id not specified, LUCI_CONTEXT should not be picked up
166 if not account_id:
167 return None
168
169 logging.debug('local_auth: requesting an access token for account "%s"',
170 account_id)
171 http = httplib2.Http()
172 host = '127.0.0.1:%d' % rpc_port
173 resp, content = http.request(
174 uri='http://%s/rpc/LuciLocalAuthService.GetOAuthToken' % host,
175 method='POST',
176 body=json.dumps({
177 'account_id': account_id,
178 'scopes': OAUTH_SCOPES.split(' '),
179 'secret': secret,
180 }),
181 headers={'Content-Type': 'application/json'})
182 if resp.status != 200:
183 err = ('local_auth: Failed to grab access token from '
184 'LUCI context server with status %d: %r')
185 authErr(err, resp.status, content)
186 try:
187 token = json.loads(content)
188 error_code = token.get('error_code')
189 error_message = token.get('error_message')
190 access_token = token.get('access_token')
191 expiry = token.get('expiry')
192 except (AttributeError, ValueError):
193 authErr('local_auth: Unexpected access token response format')
194 if error_code:
195 authErr('local_auth: Error %d in retrieving access token: %s',
196 error_code, error_message)
197 if not access_token:
198 authErr('local_auth: No access token returned from LUCI context server')
199 expiry_dt = None
200 if expiry:
201 try:
202 expiry_dt = datetime.datetime.utcfromtimestamp(expiry)
203 except (TypeError, ValueError):
204 authErr('Invalid expiry in returned token')
205 logging.debug(
206 'local_auth: got an access token for account "%s" that expires in %d sec',
207 account_id, expiry - time.mktime(now.timetuple()))
208 access_token = AccessToken(access_token, expiry_dt)
209 if _needs_refresh(access_token, now=now):
210 authErr('local_auth: the returned access token needs to be refreshed')
211 return access_token
212
213
214def _load_luci_context(ctx_path):
215 with open(ctx_path) as f:
216 return json.load(f)
217
218
vadimsh@chromium.orgcf6a5d22015-04-09 22:02:00 +0000219def make_auth_config(
220 use_oauth2=None,
221 save_cookies=None,
222 use_local_webserver=None,
vadimsh@chromium.org24daf9e2015-04-17 02:42:44 +0000223 webserver_port=None,
224 refresh_token_json=None):
vadimsh@chromium.orgcf6a5d22015-04-09 22:02:00 +0000225 """Returns new instance of AuthConfig.
226
227 If some config option is None, it will be set to a reasonable default value.
228 This function also acts as an authoritative place for default values of
229 corresponding command line options.
230 """
231 default = lambda val, d: val if val is not None else d
232 return AuthConfig(
vadimsh@chromium.org19f3fe62015-04-20 17:03:10 +0000233 default(use_oauth2, True),
vadimsh@chromium.orgcf6a5d22015-04-09 22:02:00 +0000234 default(save_cookies, True),
vadimsh@chromium.orgeed4df32015-04-10 21:30:20 +0000235 default(use_local_webserver, not _is_headless()),
vadimsh@chromium.org24daf9e2015-04-17 02:42:44 +0000236 default(webserver_port, 8090),
237 default(refresh_token_json, ''))
vadimsh@chromium.orgcf6a5d22015-04-09 22:02:00 +0000238
239
vadimsh@chromium.orgeed4df32015-04-10 21:30:20 +0000240def add_auth_options(parser, default_config=None):
vadimsh@chromium.orgcf6a5d22015-04-09 22:02:00 +0000241 """Appends OAuth related options to OptionParser."""
vadimsh@chromium.orgeed4df32015-04-10 21:30:20 +0000242 default_config = default_config or make_auth_config()
vadimsh@chromium.orgcf6a5d22015-04-09 22:02:00 +0000243 parser.auth_group = optparse.OptionGroup(parser, 'Auth options')
vadimsh@chromium.orgeed4df32015-04-10 21:30:20 +0000244 parser.add_option_group(parser.auth_group)
245
246 # OAuth2 vs password switch.
247 auth_default = 'use OAuth2' if default_config.use_oauth2 else 'use password'
vadimsh@chromium.orgcf6a5d22015-04-09 22:02:00 +0000248 parser.auth_group.add_option(
249 '--oauth2',
250 action='store_true',
251 dest='use_oauth2',
252 default=default_config.use_oauth2,
vadimsh@chromium.orgeed4df32015-04-10 21:30:20 +0000253 help='Use OAuth 2.0 instead of a password. [default: %s]' % auth_default)
vadimsh@chromium.orgcf6a5d22015-04-09 22:02:00 +0000254 parser.auth_group.add_option(
255 '--no-oauth2',
256 action='store_false',
257 dest='use_oauth2',
258 default=default_config.use_oauth2,
vadimsh@chromium.orgeed4df32015-04-10 21:30:20 +0000259 help='Use password instead of OAuth 2.0. [default: %s]' % auth_default)
260
261 # Password related options, deprecated.
vadimsh@chromium.orgcf6a5d22015-04-09 22:02:00 +0000262 parser.auth_group.add_option(
263 '--no-cookies',
264 action='store_false',
265 dest='save_cookies',
266 default=default_config.save_cookies,
267 help='Do not save authentication cookies to local disk.')
vadimsh@chromium.orgeed4df32015-04-10 21:30:20 +0000268
269 # OAuth2 related options.
vadimsh@chromium.orgcf6a5d22015-04-09 22:02:00 +0000270 parser.auth_group.add_option(
271 '--auth-no-local-webserver',
272 action='store_false',
273 dest='use_local_webserver',
274 default=default_config.use_local_webserver,
275 help='Do not run a local web server when performing OAuth2 login flow.')
276 parser.auth_group.add_option(
277 '--auth-host-port',
278 type=int,
279 default=default_config.webserver_port,
280 help='Port a local web server should listen on. Used only if '
281 '--auth-no-local-webserver is not set. [default: %default]')
vadimsh@chromium.org24daf9e2015-04-17 02:42:44 +0000282 parser.auth_group.add_option(
283 '--auth-refresh-token-json',
284 default=default_config.refresh_token_json,
285 help='Path to a JSON file with role account refresh token to use.')
vadimsh@chromium.orgcf6a5d22015-04-09 22:02:00 +0000286
287
288def extract_auth_config_from_options(options):
289 """Given OptionParser parsed options, extracts AuthConfig from it.
290
291 OptionParser should be populated with auth options by 'add_auth_options'.
292 """
293 return make_auth_config(
294 use_oauth2=options.use_oauth2,
295 save_cookies=False if options.use_oauth2 else options.save_cookies,
296 use_local_webserver=options.use_local_webserver,
vadimsh@chromium.org24daf9e2015-04-17 02:42:44 +0000297 webserver_port=options.auth_host_port,
298 refresh_token_json=options.auth_refresh_token_json)
vadimsh@chromium.orgcf6a5d22015-04-09 22:02:00 +0000299
300
301def auth_config_to_command_options(auth_config):
vadimsh@chromium.orgeed4df32015-04-10 21:30:20 +0000302 """AuthConfig -> list of strings with command line options.
303
304 Omits options that are set to default values.
305 """
vadimsh@chromium.orgcf6a5d22015-04-09 22:02:00 +0000306 if not auth_config:
307 return []
vadimsh@chromium.orgeed4df32015-04-10 21:30:20 +0000308 defaults = make_auth_config()
309 opts = []
310 if auth_config.use_oauth2 != defaults.use_oauth2:
311 opts.append('--oauth2' if auth_config.use_oauth2 else '--no-oauth2')
312 if auth_config.save_cookies != auth_config.save_cookies:
313 if not auth_config.save_cookies:
314 opts.append('--no-cookies')
315 if auth_config.use_local_webserver != defaults.use_local_webserver:
316 if not auth_config.use_local_webserver:
317 opts.append('--auth-no-local-webserver')
318 if auth_config.webserver_port != defaults.webserver_port:
319 opts.extend(['--auth-host-port', str(auth_config.webserver_port)])
vadimsh@chromium.org24daf9e2015-04-17 02:42:44 +0000320 if auth_config.refresh_token_json != defaults.refresh_token_json:
321 opts.extend([
322 '--auth-refresh-token-json', str(auth_config.refresh_token_json)])
vadimsh@chromium.orgcf6a5d22015-04-09 22:02:00 +0000323 return opts
vadimsh@chromium.orgeed4df32015-04-10 21:30:20 +0000324
325
326def get_authenticator_for_host(hostname, config):
327 """Returns Authenticator instance to access given host.
328
329 Args:
330 hostname: a naked hostname or http(s)://<hostname>[/] URL. Used to derive
331 a cache key for token cache.
332 config: AuthConfig instance.
333
334 Returns:
335 Authenticator object.
Mun Yong Jangacc8e3e2017-11-22 10:49:56 -0800336
337 Raises:
338 AuthenticationError if hostname is invalid.
vadimsh@chromium.orgeed4df32015-04-10 21:30:20 +0000339 """
340 hostname = hostname.lower().rstrip('/')
341 # Append some scheme, otherwise urlparse puts hostname into parsed.path.
342 if '://' not in hostname:
343 hostname = 'https://' + hostname
seanmccullough@chromium.org3e4a5812015-06-11 17:48:47 +0000344 scopes = OAUTH_SCOPES
vadimsh@chromium.orgeed4df32015-04-10 21:30:20 +0000345 parsed = urlparse.urlparse(hostname)
seanmccullough@chromium.org3e4a5812015-06-11 17:48:47 +0000346 if parsed.netloc in ADDITIONAL_SCOPES:
347 scopes = "%s %s" % (scopes, ADDITIONAL_SCOPES[parsed.netloc])
348
vadimsh@chromium.orgeed4df32015-04-10 21:30:20 +0000349 if parsed.path or parsed.params or parsed.query or parsed.fragment:
350 raise AuthenticationError(
351 'Expecting a hostname or root host URL, got %s instead' % hostname)
seanmccullough@chromium.org3e4a5812015-06-11 17:48:47 +0000352 return Authenticator(parsed.netloc, config, scopes)
vadimsh@chromium.orgeed4df32015-04-10 21:30:20 +0000353
354
355class Authenticator(object):
356 """Object that knows how to refresh access tokens when needed.
357
358 Args:
359 token_cache_key: string key of a section of the token cache file to use
360 to keep the tokens. See hostname_to_token_cache_key.
361 config: AuthConfig object that holds authentication configuration.
362 """
363
seanmccullough@chromium.org3e4a5812015-06-11 17:48:47 +0000364 def __init__(self, token_cache_key, config, scopes):
vadimsh@chromium.orgeed4df32015-04-10 21:30:20 +0000365 assert isinstance(config, AuthConfig)
366 assert config.use_oauth2
367 self._access_token = None
368 self._config = config
369 self._lock = threading.Lock()
370 self._token_cache_key = token_cache_key
vadimsh@chromium.org24daf9e2015-04-17 02:42:44 +0000371 self._external_token = None
seanmccullough@chromium.org3e4a5812015-06-11 17:48:47 +0000372 self._scopes = scopes
vadimsh@chromium.org24daf9e2015-04-17 02:42:44 +0000373 if config.refresh_token_json:
374 self._external_token = _read_refresh_token_json(config.refresh_token_json)
vadimsh@chromium.orgcfbeecb2015-04-21 00:12:36 +0000375 logging.debug('Using auth config %r', config)
vadimsh@chromium.orgeed4df32015-04-10 21:30:20 +0000376
377 def login(self):
378 """Performs interactive login flow if necessary.
379
380 Raises:
381 AuthenticationError on error or if interrupted.
382 """
vadimsh@chromium.org24daf9e2015-04-17 02:42:44 +0000383 if self._external_token:
384 raise AuthenticationError(
385 'Can\'t run login flow when using --auth-refresh-token-json.')
vadimsh@chromium.orgeed4df32015-04-10 21:30:20 +0000386 return self.get_access_token(
387 force_refresh=True, allow_user_interaction=True)
388
389 def logout(self):
390 """Revokes the refresh token and deletes it from the cache.
391
vadimsh@chromium.org24daf9e2015-04-17 02:42:44 +0000392 Returns True if had some credentials cached.
vadimsh@chromium.orgeed4df32015-04-10 21:30:20 +0000393 """
vadimsh@chromium.orgeed4df32015-04-10 21:30:20 +0000394 with self._lock:
395 self._access_token = None
396 storage = self._get_storage()
397 credentials = storage.get()
vadimsh@chromium.org24daf9e2015-04-17 02:42:44 +0000398 had_creds = bool(credentials)
399 if credentials and credentials.refresh_token and credentials.revoke_uri:
400 try:
401 credentials.revoke(httplib2.Http())
402 except client.TokenRevokeError as e:
403 logging.warning('Failed to revoke refresh token: %s', e)
vadimsh@chromium.orgeed4df32015-04-10 21:30:20 +0000404 storage.delete()
vadimsh@chromium.org24daf9e2015-04-17 02:42:44 +0000405 return had_creds
vadimsh@chromium.orgeed4df32015-04-10 21:30:20 +0000406
407 def has_cached_credentials(self):
408 """Returns True if long term credentials (refresh token) are in cache.
409
410 Doesn't make network calls.
411
412 If returns False, get_access_token() later will ask for interactive login by
413 raising LoginRequiredError.
414
415 If returns True, most probably get_access_token() won't ask for interactive
416 login, though it is not guaranteed, since cached token can be already
417 revoked and there's no way to figure this out without actually trying to use
418 it.
419 """
420 with self._lock:
vadimsh@chromium.org24daf9e2015-04-17 02:42:44 +0000421 return bool(self._get_cached_credentials())
vadimsh@chromium.orgeed4df32015-04-10 21:30:20 +0000422
Mun Yong Jangacc8e3e2017-11-22 10:49:56 -0800423 def get_access_token(self, force_refresh=False, allow_user_interaction=False,
424 use_local_auth=True):
vadimsh@chromium.orgeed4df32015-04-10 21:30:20 +0000425 """Returns AccessToken, refreshing it if necessary.
426
427 Args:
428 force_refresh: forcefully refresh access token even if it is not expired.
429 allow_user_interaction: True to enable blocking for user input if needed.
Mun Yong Jangacc8e3e2017-11-22 10:49:56 -0800430 use_local_auth: default to local auth if needed.
vadimsh@chromium.orgeed4df32015-04-10 21:30:20 +0000431
432 Raises:
433 AuthenticationError on error or if authentication flow was interrupted.
434 LoginRequiredError if user interaction is required, but
435 allow_user_interaction is False.
436 """
Mun Yong Jangacc8e3e2017-11-22 10:49:56 -0800437 def get_loc_auth_tkn():
438 exi = sys.exc_info()
439 if not use_local_auth:
440 logging.error('Failed to create access token')
441 raise
442 try:
443 self._access_token = get_luci_context_access_token()
444 if not self._access_token:
445 logging.error('Failed to create access token')
446 raise
447 return self._access_token
448 except LuciContextAuthError:
449 logging.exception('Failed to use local auth')
450 raise exi[0], exi[1], exi[2]
451
vadimsh@chromium.orgeed4df32015-04-10 21:30:20 +0000452 with self._lock:
453 if force_refresh:
vadimsh@chromium.orgcfbeecb2015-04-21 00:12:36 +0000454 logging.debug('Forcing access token refresh')
Mun Yong Jangacc8e3e2017-11-22 10:49:56 -0800455 try:
456 self._access_token = self._create_access_token(allow_user_interaction)
457 return self._access_token
458 except LoginRequiredError:
459 return get_loc_auth_tkn()
vadimsh@chromium.orgeed4df32015-04-10 21:30:20 +0000460
461 # Load from on-disk cache on a first access.
462 if not self._access_token:
463 self._access_token = self._load_access_token()
464
465 # Refresh if expired or missing.
466 if not self._access_token or _needs_refresh(self._access_token):
467 # Maybe some other process already updated it, reload from the cache.
468 self._access_token = self._load_access_token()
469 # Nope, still expired, need to run the refresh flow.
470 if not self._access_token or _needs_refresh(self._access_token):
Mun Yong Jangacc8e3e2017-11-22 10:49:56 -0800471 try:
472 self._access_token = self._create_access_token(
473 allow_user_interaction)
474 except LoginRequiredError:
475 get_loc_auth_tkn()
vadimsh@chromium.orgeed4df32015-04-10 21:30:20 +0000476
477 return self._access_token
478
479 def get_token_info(self):
480 """Returns a result of /oauth2/v2/tokeninfo call with token info."""
481 access_token = self.get_access_token()
482 resp, content = httplib2.Http().request(
483 uri='https://www.googleapis.com/oauth2/v2/tokeninfo?%s' % (
484 urllib.urlencode({'access_token': access_token.token})))
485 if resp.status == 200:
486 return json.loads(content)
487 raise AuthenticationError('Failed to fetch the token info: %r' % content)
488
489 def authorize(self, http):
490 """Monkey patches authentication logic of httplib2.Http instance.
491
492 The modified http.request method will add authentication headers to each
493 request and will refresh access_tokens when a 401 is received on a
494 request.
495
496 Args:
497 http: An instance of httplib2.Http.
498
499 Returns:
500 A modified instance of http that was passed in.
501 """
502 # Adapted from oauth2client.OAuth2Credentials.authorize.
503
504 request_orig = http.request
505
506 @functools.wraps(request_orig)
507 def new_request(
508 uri, method='GET', body=None, headers=None,
509 redirections=httplib2.DEFAULT_MAX_REDIRECTS,
510 connection_type=None):
511 headers = (headers or {}).copy()
vadimsh@chromium.orgafbb0192015-04-13 23:26:31 +0000512 headers['Authorization'] = 'Bearer %s' % self.get_access_token().token
vadimsh@chromium.orgeed4df32015-04-10 21:30:20 +0000513 resp, content = request_orig(
514 uri, method, body, headers, redirections, connection_type)
515 if resp.status in client.REFRESH_STATUS_CODES:
516 logging.info('Refreshing due to a %s', resp.status)
517 access_token = self.get_access_token(force_refresh=True)
vadimsh@chromium.orgafbb0192015-04-13 23:26:31 +0000518 headers['Authorization'] = 'Bearer %s' % access_token.token
vadimsh@chromium.orgeed4df32015-04-10 21:30:20 +0000519 return request_orig(
520 uri, method, body, headers, redirections, connection_type)
521 else:
522 return (resp, content)
523
524 http.request = new_request
525 return http
526
527 ## Private methods.
528
529 def _get_storage(self):
530 """Returns oauth2client.Storage with cached tokens."""
vadimsh@chromium.org24daf9e2015-04-17 02:42:44 +0000531 # Do not mix cache keys for different externally provided tokens.
532 if self._external_token:
533 token_hash = hashlib.sha1(self._external_token.refresh_token).hexdigest()
534 cache_key = '%s:refresh_tok:%s' % (self._token_cache_key, token_hash)
535 else:
536 cache_key = self._token_cache_key
vadimsh@chromium.org148f76f2015-04-21 01:44:13 +0000537 path = _get_token_cache_path()
538 logging.debug('Using token storage %r (cache key %r)', path, cache_key)
vadimsh@chromium.orgeed4df32015-04-10 21:30:20 +0000539 return multistore_file.get_credential_storage_custom_string_key(
vadimsh@chromium.org148f76f2015-04-21 01:44:13 +0000540 path, cache_key)
vadimsh@chromium.org24daf9e2015-04-17 02:42:44 +0000541
542 def _get_cached_credentials(self):
543 """Returns oauth2client.Credentials loaded from storage."""
544 storage = self._get_storage()
545 credentials = storage.get()
546
vadimsh@chromium.orgcfbeecb2015-04-21 00:12:36 +0000547 if not credentials:
548 logging.debug('No cached token')
549 else:
550 _log_credentials_info('cached token', credentials)
551
vadimsh@chromium.org24daf9e2015-04-17 02:42:44 +0000552 # Is using --auth-refresh-token-json?
553 if self._external_token:
554 # Cached credentials are valid and match external token -> use them. It is
555 # important to reuse credentials from the storage because they contain
556 # cached access token.
557 valid = (
558 credentials and not credentials.invalid and
559 credentials.refresh_token == self._external_token.refresh_token and
560 credentials.client_id == self._external_token.client_id and
561 credentials.client_secret == self._external_token.client_secret)
562 if valid:
vadimsh@chromium.orgcfbeecb2015-04-21 00:12:36 +0000563 logging.debug('Cached credentials match external refresh token')
vadimsh@chromium.org24daf9e2015-04-17 02:42:44 +0000564 return credentials
565 # Construct new credentials from externally provided refresh token,
566 # associate them with cache storage (so that access_token will be placed
567 # in the cache later too).
vadimsh@chromium.orgcfbeecb2015-04-21 00:12:36 +0000568 logging.debug('Putting external refresh token into the cache')
vadimsh@chromium.org24daf9e2015-04-17 02:42:44 +0000569 credentials = client.OAuth2Credentials(
570 access_token=None,
571 client_id=self._external_token.client_id,
572 client_secret=self._external_token.client_secret,
573 refresh_token=self._external_token.refresh_token,
574 token_expiry=None,
575 token_uri='https://accounts.google.com/o/oauth2/token',
576 user_agent=None,
577 revoke_uri=None)
578 credentials.set_store(storage)
579 storage.put(credentials)
580 return credentials
581
582 # Not using external refresh token -> return whatever is cached.
583 return credentials if (credentials and not credentials.invalid) else None
vadimsh@chromium.orgeed4df32015-04-10 21:30:20 +0000584
585 def _load_access_token(self):
586 """Returns cached AccessToken if it is not expired yet."""
vadimsh@chromium.orgcfbeecb2015-04-21 00:12:36 +0000587 logging.debug('Reloading access token from cache')
vadimsh@chromium.org24daf9e2015-04-17 02:42:44 +0000588 creds = self._get_cached_credentials()
589 if not creds or not creds.access_token or creds.access_token_expired:
vadimsh@chromium.orgcfbeecb2015-04-21 00:12:36 +0000590 logging.debug('Access token is missing or expired')
vadimsh@chromium.orgeed4df32015-04-10 21:30:20 +0000591 return None
vadimsh@chromium.org24daf9e2015-04-17 02:42:44 +0000592 return AccessToken(str(creds.access_token), creds.token_expiry)
vadimsh@chromium.orgeed4df32015-04-10 21:30:20 +0000593
594 def _create_access_token(self, allow_user_interaction=False):
595 """Mints and caches a new access token, launching OAuth2 dance if necessary.
596
597 Uses cached refresh token, if present. In that case user interaction is not
598 required and function will finish quietly. Otherwise it will launch 3-legged
599 OAuth2 flow, that needs user interaction.
600
601 Args:
602 allow_user_interaction: if True, allow interaction with the user (e.g.
603 reading standard input, or launching a browser).
604
605 Returns:
606 AccessToken.
607
608 Raises:
609 AuthenticationError on error or if authentication flow was interrupted.
610 LoginRequiredError if user interaction is required, but
611 allow_user_interaction is False.
612 """
vadimsh@chromium.orgcfbeecb2015-04-21 00:12:36 +0000613 logging.debug(
614 'Making new access token (allow_user_interaction=%r)',
615 allow_user_interaction)
vadimsh@chromium.org24daf9e2015-04-17 02:42:44 +0000616 credentials = self._get_cached_credentials()
vadimsh@chromium.orgeed4df32015-04-10 21:30:20 +0000617
618 # 3-legged flow with (perhaps cached) refresh token.
vadimsh@chromium.orgeed4df32015-04-10 21:30:20 +0000619 refreshed = False
620 if credentials and not credentials.invalid:
621 try:
vadimsh@chromium.orgcfbeecb2015-04-21 00:12:36 +0000622 logging.debug('Attempting to refresh access_token')
vadimsh@chromium.orgeed4df32015-04-10 21:30:20 +0000623 credentials.refresh(httplib2.Http())
vadimsh@chromium.orgcfbeecb2015-04-21 00:12:36 +0000624 _log_credentials_info('refreshed token', credentials)
vadimsh@chromium.orgeed4df32015-04-10 21:30:20 +0000625 refreshed = True
626 except client.Error as err:
627 logging.warning(
vadimsh@chromium.org24daf9e2015-04-17 02:42:44 +0000628 'OAuth error during access token refresh (%s). '
vadimsh@chromium.orgeed4df32015-04-10 21:30:20 +0000629 'Attempting a full authentication flow.', err)
630
631 # Refresh token is missing or invalid, go through the full flow.
632 if not refreshed:
vadimsh@chromium.org24daf9e2015-04-17 02:42:44 +0000633 # Can't refresh externally provided token.
634 if self._external_token:
635 raise AuthenticationError(
636 'Token provided via --auth-refresh-token-json is no longer valid.')
vadimsh@chromium.orgeed4df32015-04-10 21:30:20 +0000637 if not allow_user_interaction:
vadimsh@chromium.orgcfbeecb2015-04-21 00:12:36 +0000638 logging.debug('Requesting user to login')
vadimsh@chromium.orgeed4df32015-04-10 21:30:20 +0000639 raise LoginRequiredError(self._token_cache_key)
vadimsh@chromium.orgcfbeecb2015-04-21 00:12:36 +0000640 logging.debug('Launching OAuth browser flow')
seanmccullough@chromium.org3e4a5812015-06-11 17:48:47 +0000641 credentials = _run_oauth_dance(self._config, self._scopes)
vadimsh@chromium.orgcfbeecb2015-04-21 00:12:36 +0000642 _log_credentials_info('new token', credentials)
vadimsh@chromium.orgeed4df32015-04-10 21:30:20 +0000643
644 logging.info(
645 'OAuth access_token refreshed. Expires in %s.',
646 credentials.token_expiry - datetime.datetime.utcnow())
vadimsh@chromium.org24daf9e2015-04-17 02:42:44 +0000647 storage = self._get_storage()
vadimsh@chromium.orgeed4df32015-04-10 21:30:20 +0000648 credentials.set_store(storage)
649 storage.put(credentials)
vadimsh@chromium.orgafbb0192015-04-13 23:26:31 +0000650 return AccessToken(str(credentials.access_token), credentials.token_expiry)
vadimsh@chromium.orgeed4df32015-04-10 21:30:20 +0000651
652
653## Private functions.
654
655
vadimsh@chromium.org148f76f2015-04-21 01:44:13 +0000656def _get_token_cache_path():
657 # On non Win just use HOME.
658 if sys.platform != 'win32':
659 return os.path.join(os.path.expanduser('~'), OAUTH_TOKENS_CACHE)
660 # Prefer USERPROFILE over HOME, since HOME is overridden in
661 # git-..._bin/cmd/git.cmd to point to depot_tools. depot-tools-auth.py script
662 # (and all other scripts) doesn't use this override and thus uses another
663 # value for HOME. git.cmd doesn't touch USERPROFILE though, and usually
664 # USERPROFILE == HOME on Windows.
665 if 'USERPROFILE' in os.environ:
666 return os.path.join(os.environ['USERPROFILE'], OAUTH_TOKENS_CACHE)
667 return os.path.join(os.path.expanduser('~'), OAUTH_TOKENS_CACHE)
668
669
vadimsh@chromium.orgeed4df32015-04-10 21:30:20 +0000670def _is_headless():
671 """True if machine doesn't seem to have a display."""
672 return sys.platform == 'linux2' and not os.environ.get('DISPLAY')
673
674
vadimsh@chromium.org24daf9e2015-04-17 02:42:44 +0000675def _read_refresh_token_json(path):
676 """Returns RefreshToken by reading it from the JSON file."""
677 try:
678 with open(path, 'r') as f:
679 data = json.load(f)
680 return RefreshToken(
681 client_id=str(data.get('client_id', OAUTH_CLIENT_ID)),
682 client_secret=str(data.get('client_secret', OAUTH_CLIENT_SECRET)),
683 refresh_token=str(data['refresh_token']))
684 except (IOError, ValueError) as e:
685 raise AuthenticationError(
686 'Failed to read refresh token from %s: %s' % (path, e))
687 except KeyError as e:
688 raise AuthenticationError(
689 'Failed to read refresh token from %s: missing key %s' % (path, e))
690
691
Mun Yong Jangacc8e3e2017-11-22 10:49:56 -0800692def _needs_refresh(access_token, now=None):
vadimsh@chromium.orgeed4df32015-04-10 21:30:20 +0000693 """True if AccessToken should be refreshed."""
694 if access_token.expires_at is not None:
Mun Yong Jangacc8e3e2017-11-22 10:49:56 -0800695 now = now or datetime.datetime.utcnow()
vadimsh@chromium.orgeed4df32015-04-10 21:30:20 +0000696 # Allow 5 min of clock skew between client and backend.
Mun Yong Jangacc8e3e2017-11-22 10:49:56 -0800697 now += datetime.timedelta(seconds=300)
vadimsh@chromium.orgeed4df32015-04-10 21:30:20 +0000698 return now >= access_token.expires_at
699 # Token without expiration time never expires.
700 return False
701
702
vadimsh@chromium.orgcfbeecb2015-04-21 00:12:36 +0000703def _log_credentials_info(title, credentials):
704 """Dumps (non sensitive) part of client.Credentials object to debug log."""
705 if credentials:
706 logging.debug('%s info: %r', title, {
707 'access_token_expired': credentials.access_token_expired,
708 'has_access_token': bool(credentials.access_token),
709 'invalid': credentials.invalid,
710 'utcnow': datetime.datetime.utcnow(),
711 'token_expiry': credentials.token_expiry,
712 })
713
714
seanmccullough@chromium.org3e4a5812015-06-11 17:48:47 +0000715def _run_oauth_dance(config, scopes):
vadimsh@chromium.orgeed4df32015-04-10 21:30:20 +0000716 """Perform full 3-legged OAuth2 flow with the browser.
717
718 Returns:
719 oauth2client.Credentials.
720
721 Raises:
722 AuthenticationError on errors.
723 """
724 flow = client.OAuth2WebServerFlow(
725 OAUTH_CLIENT_ID,
726 OAUTH_CLIENT_SECRET,
seanmccullough@chromium.org3e4a5812015-06-11 17:48:47 +0000727 scopes,
vadimsh@chromium.orgeed4df32015-04-10 21:30:20 +0000728 approval_prompt='force')
729
730 use_local_webserver = config.use_local_webserver
731 port = config.webserver_port
732 if config.use_local_webserver:
733 success = False
734 try:
735 httpd = _ClientRedirectServer(('localhost', port), _ClientRedirectHandler)
736 except socket.error:
737 pass
738 else:
739 success = True
740 use_local_webserver = success
741 if not success:
742 print(
743 'Failed to start a local webserver listening on port %d.\n'
744 'Please check your firewall settings and locally running programs that '
745 'may be blocking or using those ports.\n\n'
746 'Falling back to --auth-no-local-webserver and continuing with '
747 'authentication.\n' % port)
748
749 if use_local_webserver:
750 oauth_callback = 'http://localhost:%s/' % port
751 else:
752 oauth_callback = client.OOB_CALLBACK_URN
753 flow.redirect_uri = oauth_callback
754 authorize_url = flow.step1_get_authorize_url()
755
756 if use_local_webserver:
757 webbrowser.open(authorize_url, new=1, autoraise=True)
758 print(
759 'Your browser has been opened to visit:\n\n'
760 ' %s\n\n'
761 'If your browser is on a different machine then exit and re-run this '
762 'application with the command-line parameter\n\n'
763 ' --auth-no-local-webserver\n' % authorize_url)
764 else:
765 print(
766 'Go to the following link in your browser:\n\n'
767 ' %s\n' % authorize_url)
768
769 try:
770 code = None
771 if use_local_webserver:
772 httpd.handle_request()
773 if 'error' in httpd.query_params:
774 raise AuthenticationError(
775 'Authentication request was rejected: %s' %
776 httpd.query_params['error'])
777 if 'code' not in httpd.query_params:
778 raise AuthenticationError(
779 'Failed to find "code" in the query parameters of the redirect.\n'
780 'Try running with --auth-no-local-webserver.')
781 code = httpd.query_params['code']
782 else:
783 code = raw_input('Enter verification code: ').strip()
784 except KeyboardInterrupt:
785 raise AuthenticationError('Authentication was canceled.')
786
787 try:
788 return flow.step2_exchange(code)
789 except client.FlowExchangeError as e:
790 raise AuthenticationError('Authentication has failed: %s' % e)
791
792
793class _ClientRedirectServer(BaseHTTPServer.HTTPServer):
794 """A server to handle OAuth 2.0 redirects back to localhost.
795
796 Waits for a single request and parses the query parameters
797 into query_params and then stops serving.
798 """
799 query_params = {}
800
801
802class _ClientRedirectHandler(BaseHTTPServer.BaseHTTPRequestHandler):
803 """A handler for OAuth 2.0 redirects back to localhost.
804
805 Waits for a single request and parses the query parameters
806 into the servers query_params and then stops serving.
807 """
808
809 def do_GET(self):
810 """Handle a GET request.
811
812 Parses the query parameters and prints a message
813 if the flow has completed. Note that we can't detect
814 if an error occurred.
815 """
816 self.send_response(200)
817 self.send_header('Content-type', 'text/html')
818 self.end_headers()
819 query = self.path.split('?', 1)[-1]
820 query = dict(urlparse.parse_qsl(query))
821 self.server.query_params = query
822 self.wfile.write('<html><head><title>Authentication Status</title></head>')
823 self.wfile.write('<body><p>The authentication flow has completed.</p>')
824 self.wfile.write('</body></html>')
825
826 def log_message(self, _format, *args):
827 """Do not log messages to stdout while running as command line program."""