blob: c415610163801428859e3eff82bf856298fcfd60 [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)
Mun Yong Jang1728f5f2017-11-27 13:29:08 -0800203 logging.debug(
204 'local_auth: got an access token for '
205 'account "%s" that expires in %d sec',
206 account_id, (expiry_dt - now).total_seconds())
Mun Yong Jangacc8e3e2017-11-22 10:49:56 -0800207 except (TypeError, ValueError):
208 authErr('Invalid expiry in returned token')
Mun Yong Jang1728f5f2017-11-27 13:29:08 -0800209 else:
210 logging.debug(
211 'local auth: got an access token for '
212 'account "%s" that does not expire',
213 account_id)
Mun Yong Jangacc8e3e2017-11-22 10:49:56 -0800214 access_token = AccessToken(access_token, expiry_dt)
215 if _needs_refresh(access_token, now=now):
216 authErr('local_auth: the returned access token needs to be refreshed')
217 return access_token
218
219
220def _load_luci_context(ctx_path):
221 with open(ctx_path) as f:
222 return json.load(f)
223
224
vadimsh@chromium.orgcf6a5d22015-04-09 22:02:00 +0000225def make_auth_config(
226 use_oauth2=None,
227 save_cookies=None,
228 use_local_webserver=None,
vadimsh@chromium.org24daf9e2015-04-17 02:42:44 +0000229 webserver_port=None,
230 refresh_token_json=None):
vadimsh@chromium.orgcf6a5d22015-04-09 22:02:00 +0000231 """Returns new instance of AuthConfig.
232
233 If some config option is None, it will be set to a reasonable default value.
234 This function also acts as an authoritative place for default values of
235 corresponding command line options.
236 """
237 default = lambda val, d: val if val is not None else d
238 return AuthConfig(
vadimsh@chromium.org19f3fe62015-04-20 17:03:10 +0000239 default(use_oauth2, True),
vadimsh@chromium.orgcf6a5d22015-04-09 22:02:00 +0000240 default(save_cookies, True),
vadimsh@chromium.orgeed4df32015-04-10 21:30:20 +0000241 default(use_local_webserver, not _is_headless()),
vadimsh@chromium.org24daf9e2015-04-17 02:42:44 +0000242 default(webserver_port, 8090),
243 default(refresh_token_json, ''))
vadimsh@chromium.orgcf6a5d22015-04-09 22:02:00 +0000244
245
vadimsh@chromium.orgeed4df32015-04-10 21:30:20 +0000246def add_auth_options(parser, default_config=None):
vadimsh@chromium.orgcf6a5d22015-04-09 22:02:00 +0000247 """Appends OAuth related options to OptionParser."""
vadimsh@chromium.orgeed4df32015-04-10 21:30:20 +0000248 default_config = default_config or make_auth_config()
vadimsh@chromium.orgcf6a5d22015-04-09 22:02:00 +0000249 parser.auth_group = optparse.OptionGroup(parser, 'Auth options')
vadimsh@chromium.orgeed4df32015-04-10 21:30:20 +0000250 parser.add_option_group(parser.auth_group)
251
252 # OAuth2 vs password switch.
253 auth_default = 'use OAuth2' if default_config.use_oauth2 else 'use password'
vadimsh@chromium.orgcf6a5d22015-04-09 22:02:00 +0000254 parser.auth_group.add_option(
255 '--oauth2',
256 action='store_true',
257 dest='use_oauth2',
258 default=default_config.use_oauth2,
vadimsh@chromium.orgeed4df32015-04-10 21:30:20 +0000259 help='Use OAuth 2.0 instead of a password. [default: %s]' % auth_default)
vadimsh@chromium.orgcf6a5d22015-04-09 22:02:00 +0000260 parser.auth_group.add_option(
261 '--no-oauth2',
262 action='store_false',
263 dest='use_oauth2',
264 default=default_config.use_oauth2,
vadimsh@chromium.orgeed4df32015-04-10 21:30:20 +0000265 help='Use password instead of OAuth 2.0. [default: %s]' % auth_default)
266
267 # Password related options, deprecated.
vadimsh@chromium.orgcf6a5d22015-04-09 22:02:00 +0000268 parser.auth_group.add_option(
269 '--no-cookies',
270 action='store_false',
271 dest='save_cookies',
272 default=default_config.save_cookies,
273 help='Do not save authentication cookies to local disk.')
vadimsh@chromium.orgeed4df32015-04-10 21:30:20 +0000274
275 # OAuth2 related options.
vadimsh@chromium.orgcf6a5d22015-04-09 22:02:00 +0000276 parser.auth_group.add_option(
277 '--auth-no-local-webserver',
278 action='store_false',
279 dest='use_local_webserver',
280 default=default_config.use_local_webserver,
281 help='Do not run a local web server when performing OAuth2 login flow.')
282 parser.auth_group.add_option(
283 '--auth-host-port',
284 type=int,
285 default=default_config.webserver_port,
286 help='Port a local web server should listen on. Used only if '
287 '--auth-no-local-webserver is not set. [default: %default]')
vadimsh@chromium.org24daf9e2015-04-17 02:42:44 +0000288 parser.auth_group.add_option(
289 '--auth-refresh-token-json',
290 default=default_config.refresh_token_json,
291 help='Path to a JSON file with role account refresh token to use.')
vadimsh@chromium.orgcf6a5d22015-04-09 22:02:00 +0000292
293
294def extract_auth_config_from_options(options):
295 """Given OptionParser parsed options, extracts AuthConfig from it.
296
297 OptionParser should be populated with auth options by 'add_auth_options'.
298 """
299 return make_auth_config(
300 use_oauth2=options.use_oauth2,
301 save_cookies=False if options.use_oauth2 else options.save_cookies,
302 use_local_webserver=options.use_local_webserver,
vadimsh@chromium.org24daf9e2015-04-17 02:42:44 +0000303 webserver_port=options.auth_host_port,
304 refresh_token_json=options.auth_refresh_token_json)
vadimsh@chromium.orgcf6a5d22015-04-09 22:02:00 +0000305
306
307def auth_config_to_command_options(auth_config):
vadimsh@chromium.orgeed4df32015-04-10 21:30:20 +0000308 """AuthConfig -> list of strings with command line options.
309
310 Omits options that are set to default values.
311 """
vadimsh@chromium.orgcf6a5d22015-04-09 22:02:00 +0000312 if not auth_config:
313 return []
vadimsh@chromium.orgeed4df32015-04-10 21:30:20 +0000314 defaults = make_auth_config()
315 opts = []
316 if auth_config.use_oauth2 != defaults.use_oauth2:
317 opts.append('--oauth2' if auth_config.use_oauth2 else '--no-oauth2')
318 if auth_config.save_cookies != auth_config.save_cookies:
319 if not auth_config.save_cookies:
320 opts.append('--no-cookies')
321 if auth_config.use_local_webserver != defaults.use_local_webserver:
322 if not auth_config.use_local_webserver:
323 opts.append('--auth-no-local-webserver')
324 if auth_config.webserver_port != defaults.webserver_port:
325 opts.extend(['--auth-host-port', str(auth_config.webserver_port)])
vadimsh@chromium.org24daf9e2015-04-17 02:42:44 +0000326 if auth_config.refresh_token_json != defaults.refresh_token_json:
327 opts.extend([
328 '--auth-refresh-token-json', str(auth_config.refresh_token_json)])
vadimsh@chromium.orgcf6a5d22015-04-09 22:02:00 +0000329 return opts
vadimsh@chromium.orgeed4df32015-04-10 21:30:20 +0000330
331
332def get_authenticator_for_host(hostname, config):
333 """Returns Authenticator instance to access given host.
334
335 Args:
336 hostname: a naked hostname or http(s)://<hostname>[/] URL. Used to derive
337 a cache key for token cache.
338 config: AuthConfig instance.
339
340 Returns:
341 Authenticator object.
Mun Yong Jangacc8e3e2017-11-22 10:49:56 -0800342
343 Raises:
344 AuthenticationError if hostname is invalid.
vadimsh@chromium.orgeed4df32015-04-10 21:30:20 +0000345 """
346 hostname = hostname.lower().rstrip('/')
347 # Append some scheme, otherwise urlparse puts hostname into parsed.path.
348 if '://' not in hostname:
349 hostname = 'https://' + hostname
seanmccullough@chromium.org3e4a5812015-06-11 17:48:47 +0000350 scopes = OAUTH_SCOPES
vadimsh@chromium.orgeed4df32015-04-10 21:30:20 +0000351 parsed = urlparse.urlparse(hostname)
seanmccullough@chromium.org3e4a5812015-06-11 17:48:47 +0000352 if parsed.netloc in ADDITIONAL_SCOPES:
353 scopes = "%s %s" % (scopes, ADDITIONAL_SCOPES[parsed.netloc])
354
vadimsh@chromium.orgeed4df32015-04-10 21:30:20 +0000355 if parsed.path or parsed.params or parsed.query or parsed.fragment:
356 raise AuthenticationError(
357 'Expecting a hostname or root host URL, got %s instead' % hostname)
seanmccullough@chromium.org3e4a5812015-06-11 17:48:47 +0000358 return Authenticator(parsed.netloc, config, scopes)
vadimsh@chromium.orgeed4df32015-04-10 21:30:20 +0000359
360
361class Authenticator(object):
362 """Object that knows how to refresh access tokens when needed.
363
364 Args:
365 token_cache_key: string key of a section of the token cache file to use
366 to keep the tokens. See hostname_to_token_cache_key.
367 config: AuthConfig object that holds authentication configuration.
368 """
369
seanmccullough@chromium.org3e4a5812015-06-11 17:48:47 +0000370 def __init__(self, token_cache_key, config, scopes):
vadimsh@chromium.orgeed4df32015-04-10 21:30:20 +0000371 assert isinstance(config, AuthConfig)
372 assert config.use_oauth2
373 self._access_token = None
374 self._config = config
375 self._lock = threading.Lock()
376 self._token_cache_key = token_cache_key
vadimsh@chromium.org24daf9e2015-04-17 02:42:44 +0000377 self._external_token = None
seanmccullough@chromium.org3e4a5812015-06-11 17:48:47 +0000378 self._scopes = scopes
vadimsh@chromium.org24daf9e2015-04-17 02:42:44 +0000379 if config.refresh_token_json:
380 self._external_token = _read_refresh_token_json(config.refresh_token_json)
vadimsh@chromium.orgcfbeecb2015-04-21 00:12:36 +0000381 logging.debug('Using auth config %r', config)
vadimsh@chromium.orgeed4df32015-04-10 21:30:20 +0000382
383 def login(self):
384 """Performs interactive login flow if necessary.
385
386 Raises:
387 AuthenticationError on error or if interrupted.
388 """
vadimsh@chromium.org24daf9e2015-04-17 02:42:44 +0000389 if self._external_token:
390 raise AuthenticationError(
391 'Can\'t run login flow when using --auth-refresh-token-json.')
vadimsh@chromium.orgeed4df32015-04-10 21:30:20 +0000392 return self.get_access_token(
393 force_refresh=True, allow_user_interaction=True)
394
395 def logout(self):
396 """Revokes the refresh token and deletes it from the cache.
397
vadimsh@chromium.org24daf9e2015-04-17 02:42:44 +0000398 Returns True if had some credentials cached.
vadimsh@chromium.orgeed4df32015-04-10 21:30:20 +0000399 """
vadimsh@chromium.orgeed4df32015-04-10 21:30:20 +0000400 with self._lock:
401 self._access_token = None
402 storage = self._get_storage()
403 credentials = storage.get()
vadimsh@chromium.org24daf9e2015-04-17 02:42:44 +0000404 had_creds = bool(credentials)
405 if credentials and credentials.refresh_token and credentials.revoke_uri:
406 try:
407 credentials.revoke(httplib2.Http())
408 except client.TokenRevokeError as e:
409 logging.warning('Failed to revoke refresh token: %s', e)
vadimsh@chromium.orgeed4df32015-04-10 21:30:20 +0000410 storage.delete()
vadimsh@chromium.org24daf9e2015-04-17 02:42:44 +0000411 return had_creds
vadimsh@chromium.orgeed4df32015-04-10 21:30:20 +0000412
413 def has_cached_credentials(self):
414 """Returns True if long term credentials (refresh token) are in cache.
415
416 Doesn't make network calls.
417
418 If returns False, get_access_token() later will ask for interactive login by
419 raising LoginRequiredError.
420
421 If returns True, most probably get_access_token() won't ask for interactive
422 login, though it is not guaranteed, since cached token can be already
423 revoked and there's no way to figure this out without actually trying to use
424 it.
425 """
426 with self._lock:
vadimsh@chromium.org24daf9e2015-04-17 02:42:44 +0000427 return bool(self._get_cached_credentials())
vadimsh@chromium.orgeed4df32015-04-10 21:30:20 +0000428
Mun Yong Jangacc8e3e2017-11-22 10:49:56 -0800429 def get_access_token(self, force_refresh=False, allow_user_interaction=False,
430 use_local_auth=True):
vadimsh@chromium.orgeed4df32015-04-10 21:30:20 +0000431 """Returns AccessToken, refreshing it if necessary.
432
433 Args:
434 force_refresh: forcefully refresh access token even if it is not expired.
435 allow_user_interaction: True to enable blocking for user input if needed.
Mun Yong Jangacc8e3e2017-11-22 10:49:56 -0800436 use_local_auth: default to local auth if needed.
vadimsh@chromium.orgeed4df32015-04-10 21:30:20 +0000437
438 Raises:
439 AuthenticationError on error or if authentication flow was interrupted.
440 LoginRequiredError if user interaction is required, but
441 allow_user_interaction is False.
442 """
Mun Yong Jangacc8e3e2017-11-22 10:49:56 -0800443 def get_loc_auth_tkn():
444 exi = sys.exc_info()
445 if not use_local_auth:
446 logging.error('Failed to create access token')
447 raise
448 try:
449 self._access_token = get_luci_context_access_token()
450 if not self._access_token:
451 logging.error('Failed to create access token')
452 raise
453 return self._access_token
454 except LuciContextAuthError:
455 logging.exception('Failed to use local auth')
456 raise exi[0], exi[1], exi[2]
457
vadimsh@chromium.orgeed4df32015-04-10 21:30:20 +0000458 with self._lock:
459 if force_refresh:
vadimsh@chromium.orgcfbeecb2015-04-21 00:12:36 +0000460 logging.debug('Forcing access token refresh')
Mun Yong Jangacc8e3e2017-11-22 10:49:56 -0800461 try:
462 self._access_token = self._create_access_token(allow_user_interaction)
463 return self._access_token
464 except LoginRequiredError:
465 return get_loc_auth_tkn()
vadimsh@chromium.orgeed4df32015-04-10 21:30:20 +0000466
467 # Load from on-disk cache on a first access.
468 if not self._access_token:
469 self._access_token = self._load_access_token()
470
471 # Refresh if expired or missing.
472 if not self._access_token or _needs_refresh(self._access_token):
473 # Maybe some other process already updated it, reload from the cache.
474 self._access_token = self._load_access_token()
475 # Nope, still expired, need to run the refresh flow.
476 if not self._access_token or _needs_refresh(self._access_token):
Mun Yong Jangacc8e3e2017-11-22 10:49:56 -0800477 try:
478 self._access_token = self._create_access_token(
479 allow_user_interaction)
480 except LoginRequiredError:
481 get_loc_auth_tkn()
vadimsh@chromium.orgeed4df32015-04-10 21:30:20 +0000482
483 return self._access_token
484
485 def get_token_info(self):
486 """Returns a result of /oauth2/v2/tokeninfo call with token info."""
487 access_token = self.get_access_token()
488 resp, content = httplib2.Http().request(
489 uri='https://www.googleapis.com/oauth2/v2/tokeninfo?%s' % (
490 urllib.urlencode({'access_token': access_token.token})))
491 if resp.status == 200:
492 return json.loads(content)
493 raise AuthenticationError('Failed to fetch the token info: %r' % content)
494
495 def authorize(self, http):
496 """Monkey patches authentication logic of httplib2.Http instance.
497
498 The modified http.request method will add authentication headers to each
499 request and will refresh access_tokens when a 401 is received on a
500 request.
501
502 Args:
503 http: An instance of httplib2.Http.
504
505 Returns:
506 A modified instance of http that was passed in.
507 """
508 # Adapted from oauth2client.OAuth2Credentials.authorize.
509
510 request_orig = http.request
511
512 @functools.wraps(request_orig)
513 def new_request(
514 uri, method='GET', body=None, headers=None,
515 redirections=httplib2.DEFAULT_MAX_REDIRECTS,
516 connection_type=None):
517 headers = (headers or {}).copy()
vadimsh@chromium.orgafbb0192015-04-13 23:26:31 +0000518 headers['Authorization'] = 'Bearer %s' % self.get_access_token().token
vadimsh@chromium.orgeed4df32015-04-10 21:30:20 +0000519 resp, content = request_orig(
520 uri, method, body, headers, redirections, connection_type)
521 if resp.status in client.REFRESH_STATUS_CODES:
522 logging.info('Refreshing due to a %s', resp.status)
523 access_token = self.get_access_token(force_refresh=True)
vadimsh@chromium.orgafbb0192015-04-13 23:26:31 +0000524 headers['Authorization'] = 'Bearer %s' % access_token.token
vadimsh@chromium.orgeed4df32015-04-10 21:30:20 +0000525 return request_orig(
526 uri, method, body, headers, redirections, connection_type)
527 else:
528 return (resp, content)
529
530 http.request = new_request
531 return http
532
533 ## Private methods.
534
535 def _get_storage(self):
536 """Returns oauth2client.Storage with cached tokens."""
vadimsh@chromium.org24daf9e2015-04-17 02:42:44 +0000537 # Do not mix cache keys for different externally provided tokens.
538 if self._external_token:
539 token_hash = hashlib.sha1(self._external_token.refresh_token).hexdigest()
540 cache_key = '%s:refresh_tok:%s' % (self._token_cache_key, token_hash)
541 else:
542 cache_key = self._token_cache_key
vadimsh@chromium.org148f76f2015-04-21 01:44:13 +0000543 path = _get_token_cache_path()
544 logging.debug('Using token storage %r (cache key %r)', path, cache_key)
vadimsh@chromium.orgeed4df32015-04-10 21:30:20 +0000545 return multistore_file.get_credential_storage_custom_string_key(
vadimsh@chromium.org148f76f2015-04-21 01:44:13 +0000546 path, cache_key)
vadimsh@chromium.org24daf9e2015-04-17 02:42:44 +0000547
548 def _get_cached_credentials(self):
549 """Returns oauth2client.Credentials loaded from storage."""
550 storage = self._get_storage()
551 credentials = storage.get()
552
vadimsh@chromium.orgcfbeecb2015-04-21 00:12:36 +0000553 if not credentials:
554 logging.debug('No cached token')
555 else:
556 _log_credentials_info('cached token', credentials)
557
vadimsh@chromium.org24daf9e2015-04-17 02:42:44 +0000558 # Is using --auth-refresh-token-json?
559 if self._external_token:
560 # Cached credentials are valid and match external token -> use them. It is
561 # important to reuse credentials from the storage because they contain
562 # cached access token.
563 valid = (
564 credentials and not credentials.invalid and
565 credentials.refresh_token == self._external_token.refresh_token and
566 credentials.client_id == self._external_token.client_id and
567 credentials.client_secret == self._external_token.client_secret)
568 if valid:
vadimsh@chromium.orgcfbeecb2015-04-21 00:12:36 +0000569 logging.debug('Cached credentials match external refresh token')
vadimsh@chromium.org24daf9e2015-04-17 02:42:44 +0000570 return credentials
571 # Construct new credentials from externally provided refresh token,
572 # associate them with cache storage (so that access_token will be placed
573 # in the cache later too).
vadimsh@chromium.orgcfbeecb2015-04-21 00:12:36 +0000574 logging.debug('Putting external refresh token into the cache')
vadimsh@chromium.org24daf9e2015-04-17 02:42:44 +0000575 credentials = client.OAuth2Credentials(
576 access_token=None,
577 client_id=self._external_token.client_id,
578 client_secret=self._external_token.client_secret,
579 refresh_token=self._external_token.refresh_token,
580 token_expiry=None,
581 token_uri='https://accounts.google.com/o/oauth2/token',
582 user_agent=None,
583 revoke_uri=None)
584 credentials.set_store(storage)
585 storage.put(credentials)
586 return credentials
587
588 # Not using external refresh token -> return whatever is cached.
589 return credentials if (credentials and not credentials.invalid) else None
vadimsh@chromium.orgeed4df32015-04-10 21:30:20 +0000590
591 def _load_access_token(self):
592 """Returns cached AccessToken if it is not expired yet."""
vadimsh@chromium.orgcfbeecb2015-04-21 00:12:36 +0000593 logging.debug('Reloading access token from cache')
vadimsh@chromium.org24daf9e2015-04-17 02:42:44 +0000594 creds = self._get_cached_credentials()
595 if not creds or not creds.access_token or creds.access_token_expired:
vadimsh@chromium.orgcfbeecb2015-04-21 00:12:36 +0000596 logging.debug('Access token is missing or expired')
vadimsh@chromium.orgeed4df32015-04-10 21:30:20 +0000597 return None
vadimsh@chromium.org24daf9e2015-04-17 02:42:44 +0000598 return AccessToken(str(creds.access_token), creds.token_expiry)
vadimsh@chromium.orgeed4df32015-04-10 21:30:20 +0000599
600 def _create_access_token(self, allow_user_interaction=False):
601 """Mints and caches a new access token, launching OAuth2 dance if necessary.
602
603 Uses cached refresh token, if present. In that case user interaction is not
604 required and function will finish quietly. Otherwise it will launch 3-legged
605 OAuth2 flow, that needs user interaction.
606
607 Args:
608 allow_user_interaction: if True, allow interaction with the user (e.g.
609 reading standard input, or launching a browser).
610
611 Returns:
612 AccessToken.
613
614 Raises:
615 AuthenticationError on error or if authentication flow was interrupted.
616 LoginRequiredError if user interaction is required, but
617 allow_user_interaction is False.
618 """
vadimsh@chromium.orgcfbeecb2015-04-21 00:12:36 +0000619 logging.debug(
620 'Making new access token (allow_user_interaction=%r)',
621 allow_user_interaction)
vadimsh@chromium.org24daf9e2015-04-17 02:42:44 +0000622 credentials = self._get_cached_credentials()
vadimsh@chromium.orgeed4df32015-04-10 21:30:20 +0000623
624 # 3-legged flow with (perhaps cached) refresh token.
vadimsh@chromium.orgeed4df32015-04-10 21:30:20 +0000625 refreshed = False
626 if credentials and not credentials.invalid:
627 try:
vadimsh@chromium.orgcfbeecb2015-04-21 00:12:36 +0000628 logging.debug('Attempting to refresh access_token')
vadimsh@chromium.orgeed4df32015-04-10 21:30:20 +0000629 credentials.refresh(httplib2.Http())
vadimsh@chromium.orgcfbeecb2015-04-21 00:12:36 +0000630 _log_credentials_info('refreshed token', credentials)
vadimsh@chromium.orgeed4df32015-04-10 21:30:20 +0000631 refreshed = True
632 except client.Error as err:
633 logging.warning(
vadimsh@chromium.org24daf9e2015-04-17 02:42:44 +0000634 'OAuth error during access token refresh (%s). '
vadimsh@chromium.orgeed4df32015-04-10 21:30:20 +0000635 'Attempting a full authentication flow.', err)
636
637 # Refresh token is missing or invalid, go through the full flow.
638 if not refreshed:
vadimsh@chromium.org24daf9e2015-04-17 02:42:44 +0000639 # Can't refresh externally provided token.
640 if self._external_token:
641 raise AuthenticationError(
642 'Token provided via --auth-refresh-token-json is no longer valid.')
vadimsh@chromium.orgeed4df32015-04-10 21:30:20 +0000643 if not allow_user_interaction:
vadimsh@chromium.orgcfbeecb2015-04-21 00:12:36 +0000644 logging.debug('Requesting user to login')
vadimsh@chromium.orgeed4df32015-04-10 21:30:20 +0000645 raise LoginRequiredError(self._token_cache_key)
vadimsh@chromium.orgcfbeecb2015-04-21 00:12:36 +0000646 logging.debug('Launching OAuth browser flow')
seanmccullough@chromium.org3e4a5812015-06-11 17:48:47 +0000647 credentials = _run_oauth_dance(self._config, self._scopes)
vadimsh@chromium.orgcfbeecb2015-04-21 00:12:36 +0000648 _log_credentials_info('new token', credentials)
vadimsh@chromium.orgeed4df32015-04-10 21:30:20 +0000649
650 logging.info(
651 'OAuth access_token refreshed. Expires in %s.',
652 credentials.token_expiry - datetime.datetime.utcnow())
vadimsh@chromium.org24daf9e2015-04-17 02:42:44 +0000653 storage = self._get_storage()
vadimsh@chromium.orgeed4df32015-04-10 21:30:20 +0000654 credentials.set_store(storage)
655 storage.put(credentials)
vadimsh@chromium.orgafbb0192015-04-13 23:26:31 +0000656 return AccessToken(str(credentials.access_token), credentials.token_expiry)
vadimsh@chromium.orgeed4df32015-04-10 21:30:20 +0000657
658
659## Private functions.
660
661
vadimsh@chromium.org148f76f2015-04-21 01:44:13 +0000662def _get_token_cache_path():
663 # On non Win just use HOME.
664 if sys.platform != 'win32':
665 return os.path.join(os.path.expanduser('~'), OAUTH_TOKENS_CACHE)
666 # Prefer USERPROFILE over HOME, since HOME is overridden in
667 # git-..._bin/cmd/git.cmd to point to depot_tools. depot-tools-auth.py script
668 # (and all other scripts) doesn't use this override and thus uses another
669 # value for HOME. git.cmd doesn't touch USERPROFILE though, and usually
670 # USERPROFILE == HOME on Windows.
671 if 'USERPROFILE' in os.environ:
672 return os.path.join(os.environ['USERPROFILE'], OAUTH_TOKENS_CACHE)
673 return os.path.join(os.path.expanduser('~'), OAUTH_TOKENS_CACHE)
674
675
vadimsh@chromium.orgeed4df32015-04-10 21:30:20 +0000676def _is_headless():
677 """True if machine doesn't seem to have a display."""
678 return sys.platform == 'linux2' and not os.environ.get('DISPLAY')
679
680
vadimsh@chromium.org24daf9e2015-04-17 02:42:44 +0000681def _read_refresh_token_json(path):
682 """Returns RefreshToken by reading it from the JSON file."""
683 try:
684 with open(path, 'r') as f:
685 data = json.load(f)
686 return RefreshToken(
687 client_id=str(data.get('client_id', OAUTH_CLIENT_ID)),
688 client_secret=str(data.get('client_secret', OAUTH_CLIENT_SECRET)),
689 refresh_token=str(data['refresh_token']))
690 except (IOError, ValueError) as e:
691 raise AuthenticationError(
692 'Failed to read refresh token from %s: %s' % (path, e))
693 except KeyError as e:
694 raise AuthenticationError(
695 'Failed to read refresh token from %s: missing key %s' % (path, e))
696
697
Mun Yong Jangacc8e3e2017-11-22 10:49:56 -0800698def _needs_refresh(access_token, now=None):
vadimsh@chromium.orgeed4df32015-04-10 21:30:20 +0000699 """True if AccessToken should be refreshed."""
700 if access_token.expires_at is not None:
Mun Yong Jangacc8e3e2017-11-22 10:49:56 -0800701 now = now or datetime.datetime.utcnow()
vadimsh@chromium.orgeed4df32015-04-10 21:30:20 +0000702 # Allow 5 min of clock skew between client and backend.
Mun Yong Jangacc8e3e2017-11-22 10:49:56 -0800703 now += datetime.timedelta(seconds=300)
vadimsh@chromium.orgeed4df32015-04-10 21:30:20 +0000704 return now >= access_token.expires_at
705 # Token without expiration time never expires.
706 return False
707
708
vadimsh@chromium.orgcfbeecb2015-04-21 00:12:36 +0000709def _log_credentials_info(title, credentials):
710 """Dumps (non sensitive) part of client.Credentials object to debug log."""
711 if credentials:
712 logging.debug('%s info: %r', title, {
713 'access_token_expired': credentials.access_token_expired,
714 'has_access_token': bool(credentials.access_token),
715 'invalid': credentials.invalid,
716 'utcnow': datetime.datetime.utcnow(),
717 'token_expiry': credentials.token_expiry,
718 })
719
720
seanmccullough@chromium.org3e4a5812015-06-11 17:48:47 +0000721def _run_oauth_dance(config, scopes):
vadimsh@chromium.orgeed4df32015-04-10 21:30:20 +0000722 """Perform full 3-legged OAuth2 flow with the browser.
723
724 Returns:
725 oauth2client.Credentials.
726
727 Raises:
728 AuthenticationError on errors.
729 """
730 flow = client.OAuth2WebServerFlow(
731 OAUTH_CLIENT_ID,
732 OAUTH_CLIENT_SECRET,
seanmccullough@chromium.org3e4a5812015-06-11 17:48:47 +0000733 scopes,
vadimsh@chromium.orgeed4df32015-04-10 21:30:20 +0000734 approval_prompt='force')
735
736 use_local_webserver = config.use_local_webserver
737 port = config.webserver_port
738 if config.use_local_webserver:
739 success = False
740 try:
741 httpd = _ClientRedirectServer(('localhost', port), _ClientRedirectHandler)
742 except socket.error:
743 pass
744 else:
745 success = True
746 use_local_webserver = success
747 if not success:
748 print(
749 'Failed to start a local webserver listening on port %d.\n'
750 'Please check your firewall settings and locally running programs that '
751 'may be blocking or using those ports.\n\n'
752 'Falling back to --auth-no-local-webserver and continuing with '
753 'authentication.\n' % port)
754
755 if use_local_webserver:
756 oauth_callback = 'http://localhost:%s/' % port
757 else:
758 oauth_callback = client.OOB_CALLBACK_URN
759 flow.redirect_uri = oauth_callback
760 authorize_url = flow.step1_get_authorize_url()
761
762 if use_local_webserver:
763 webbrowser.open(authorize_url, new=1, autoraise=True)
764 print(
765 'Your browser has been opened to visit:\n\n'
766 ' %s\n\n'
767 'If your browser is on a different machine then exit and re-run this '
768 'application with the command-line parameter\n\n'
769 ' --auth-no-local-webserver\n' % authorize_url)
770 else:
771 print(
772 'Go to the following link in your browser:\n\n'
773 ' %s\n' % authorize_url)
774
775 try:
776 code = None
777 if use_local_webserver:
778 httpd.handle_request()
779 if 'error' in httpd.query_params:
780 raise AuthenticationError(
781 'Authentication request was rejected: %s' %
782 httpd.query_params['error'])
783 if 'code' not in httpd.query_params:
784 raise AuthenticationError(
785 'Failed to find "code" in the query parameters of the redirect.\n'
786 'Try running with --auth-no-local-webserver.')
787 code = httpd.query_params['code']
788 else:
789 code = raw_input('Enter verification code: ').strip()
790 except KeyboardInterrupt:
791 raise AuthenticationError('Authentication was canceled.')
792
793 try:
794 return flow.step2_exchange(code)
795 except client.FlowExchangeError as e:
796 raise AuthenticationError('Authentication has failed: %s' % e)
797
798
799class _ClientRedirectServer(BaseHTTPServer.HTTPServer):
800 """A server to handle OAuth 2.0 redirects back to localhost.
801
802 Waits for a single request and parses the query parameters
803 into query_params and then stops serving.
804 """
805 query_params = {}
806
807
808class _ClientRedirectHandler(BaseHTTPServer.BaseHTTPRequestHandler):
809 """A handler for OAuth 2.0 redirects back to localhost.
810
811 Waits for a single request and parses the query parameters
812 into the servers query_params and then stops serving.
813 """
814
815 def do_GET(self):
816 """Handle a GET request.
817
818 Parses the query parameters and prints a message
819 if the flow has completed. Note that we can't detect
820 if an error occurred.
821 """
822 self.send_response(200)
823 self.send_header('Content-type', 'text/html')
824 self.end_headers()
825 query = self.path.split('?', 1)[-1]
826 query = dict(urlparse.parse_qsl(query))
827 self.server.query_params = query
828 self.wfile.write('<html><head><title>Authentication Status</title></head>')
829 self.wfile.write('<body><p>The authentication flow has completed.</p>')
830 self.wfile.write('</body></html>')
831
832 def log_message(self, _format, *args):
833 """Do not log messages to stdout while running as command line program."""