blob: 6e0d2f32f4957e79befbac092fcd66df18916ac2 [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
19import urllib
20import urlparse
21import webbrowser
22
23from third_party import httplib2
24from third_party.oauth2client import client
25from third_party.oauth2client import multistore_file
26
27
28# depot_tools/.
29DEPOT_TOOLS_DIR = os.path.dirname(os.path.abspath(__file__))
30
31
32# Google OAuth2 clients always have a secret, even if the client is an installed
33# application/utility such as this. Of course, in such cases the "secret" is
34# actually publicly known; security depends entirely on the secrecy of refresh
35# tokens, which effectively become bearer tokens. An attacker can impersonate
36# service's identity in OAuth2 flow. But that's generally fine as long as a list
37# of allowed redirect_uri's associated with client_id is limited to 'localhost'
38# or 'urn:ietf:wg:oauth:2.0:oob'. In that case attacker needs some process
39# running on user's machine to successfully complete the flow and grab refresh
40# token. When you have a malicious code running on your machine, you're screwed
41# anyway.
42# This particular set is managed by API Console project "chrome-infra-auth".
43OAUTH_CLIENT_ID = (
44 '446450136466-2hr92jrq8e6i4tnsa56b52vacp7t3936.apps.googleusercontent.com')
45OAUTH_CLIENT_SECRET = 'uBfbay2KCy9t4QveJ-dOqHtp'
46
47# List of space separated OAuth scopes for generated tokens. GAE apps usually
48# use userinfo.email scope for authentication.
49OAUTH_SCOPES = 'https://www.googleapis.com/auth/userinfo.email'
50
seanmccullough@chromium.org3e4a5812015-06-11 17:48:47 +000051# Additional OAuth scopes.
52ADDITIONAL_SCOPES = {
53 'code.google.com': 'https://www.googleapis.com/auth/projecthosting',
54}
55
vadimsh@chromium.org148f76f2015-04-21 01:44:13 +000056# Path to a file with cached OAuth2 credentials used by default relative to the
57# home dir (see _get_token_cache_path). It should be a safe location accessible
58# only to a current user: knowing content of this file is roughly equivalent to
59# knowing account password. Single file can hold multiple independent tokens
60# identified by token_cache_key (see Authenticator).
61OAUTH_TOKENS_CACHE = '.depot_tools_oauth2_tokens'
vadimsh@chromium.orgcf6a5d22015-04-09 22:02:00 +000062
63
64# Authentication configuration extracted from command line options.
65# See doc string for 'make_auth_config' for meaning of fields.
66AuthConfig = collections.namedtuple('AuthConfig', [
67 'use_oauth2', # deprecated, will be always True
68 'save_cookies', # deprecated, will be removed
69 'use_local_webserver',
70 'webserver_port',
vadimsh@chromium.org24daf9e2015-04-17 02:42:44 +000071 'refresh_token_json',
vadimsh@chromium.orgcf6a5d22015-04-09 22:02:00 +000072])
73
74
vadimsh@chromium.orgeed4df32015-04-10 21:30:20 +000075# OAuth access token with its expiration time (UTC datetime or None if unknown).
76AccessToken = collections.namedtuple('AccessToken', [
77 'token',
78 'expires_at',
79])
80
81
vadimsh@chromium.org24daf9e2015-04-17 02:42:44 +000082# Refresh token passed via --auth-refresh-token-json.
83RefreshToken = collections.namedtuple('RefreshToken', [
84 'client_id',
85 'client_secret',
86 'refresh_token',
87])
88
89
vadimsh@chromium.orgeed4df32015-04-10 21:30:20 +000090class AuthenticationError(Exception):
91 """Raised on errors related to authentication."""
92
93
94class LoginRequiredError(AuthenticationError):
95 """Interaction with the user is required to authenticate."""
96
97 def __init__(self, token_cache_key):
98 # HACK(vadimsh): It is assumed here that the token cache key is a hostname.
99 msg = (
100 'You are not logged in. Please login first by running:\n'
101 ' depot-tools-auth login %s' % token_cache_key)
102 super(LoginRequiredError, self).__init__(msg)
103
104
vadimsh@chromium.orgcf6a5d22015-04-09 22:02:00 +0000105def make_auth_config(
106 use_oauth2=None,
107 save_cookies=None,
108 use_local_webserver=None,
vadimsh@chromium.org24daf9e2015-04-17 02:42:44 +0000109 webserver_port=None,
110 refresh_token_json=None):
vadimsh@chromium.orgcf6a5d22015-04-09 22:02:00 +0000111 """Returns new instance of AuthConfig.
112
113 If some config option is None, it will be set to a reasonable default value.
114 This function also acts as an authoritative place for default values of
115 corresponding command line options.
116 """
117 default = lambda val, d: val if val is not None else d
118 return AuthConfig(
vadimsh@chromium.org19f3fe62015-04-20 17:03:10 +0000119 default(use_oauth2, True),
vadimsh@chromium.orgcf6a5d22015-04-09 22:02:00 +0000120 default(save_cookies, True),
vadimsh@chromium.orgeed4df32015-04-10 21:30:20 +0000121 default(use_local_webserver, not _is_headless()),
vadimsh@chromium.org24daf9e2015-04-17 02:42:44 +0000122 default(webserver_port, 8090),
123 default(refresh_token_json, ''))
vadimsh@chromium.orgcf6a5d22015-04-09 22:02:00 +0000124
125
vadimsh@chromium.orgeed4df32015-04-10 21:30:20 +0000126def add_auth_options(parser, default_config=None):
vadimsh@chromium.orgcf6a5d22015-04-09 22:02:00 +0000127 """Appends OAuth related options to OptionParser."""
vadimsh@chromium.orgeed4df32015-04-10 21:30:20 +0000128 default_config = default_config or make_auth_config()
vadimsh@chromium.orgcf6a5d22015-04-09 22:02:00 +0000129 parser.auth_group = optparse.OptionGroup(parser, 'Auth options')
vadimsh@chromium.orgeed4df32015-04-10 21:30:20 +0000130 parser.add_option_group(parser.auth_group)
131
132 # OAuth2 vs password switch.
133 auth_default = 'use OAuth2' if default_config.use_oauth2 else 'use password'
vadimsh@chromium.orgcf6a5d22015-04-09 22:02:00 +0000134 parser.auth_group.add_option(
135 '--oauth2',
136 action='store_true',
137 dest='use_oauth2',
138 default=default_config.use_oauth2,
vadimsh@chromium.orgeed4df32015-04-10 21:30:20 +0000139 help='Use OAuth 2.0 instead of a password. [default: %s]' % auth_default)
vadimsh@chromium.orgcf6a5d22015-04-09 22:02:00 +0000140 parser.auth_group.add_option(
141 '--no-oauth2',
142 action='store_false',
143 dest='use_oauth2',
144 default=default_config.use_oauth2,
vadimsh@chromium.orgeed4df32015-04-10 21:30:20 +0000145 help='Use password instead of OAuth 2.0. [default: %s]' % auth_default)
146
147 # Password related options, deprecated.
vadimsh@chromium.orgcf6a5d22015-04-09 22:02:00 +0000148 parser.auth_group.add_option(
149 '--no-cookies',
150 action='store_false',
151 dest='save_cookies',
152 default=default_config.save_cookies,
153 help='Do not save authentication cookies to local disk.')
vadimsh@chromium.orgeed4df32015-04-10 21:30:20 +0000154
155 # OAuth2 related options.
vadimsh@chromium.orgcf6a5d22015-04-09 22:02:00 +0000156 parser.auth_group.add_option(
157 '--auth-no-local-webserver',
158 action='store_false',
159 dest='use_local_webserver',
160 default=default_config.use_local_webserver,
161 help='Do not run a local web server when performing OAuth2 login flow.')
162 parser.auth_group.add_option(
163 '--auth-host-port',
164 type=int,
165 default=default_config.webserver_port,
166 help='Port a local web server should listen on. Used only if '
167 '--auth-no-local-webserver is not set. [default: %default]')
vadimsh@chromium.org24daf9e2015-04-17 02:42:44 +0000168 parser.auth_group.add_option(
169 '--auth-refresh-token-json',
170 default=default_config.refresh_token_json,
171 help='Path to a JSON file with role account refresh token to use.')
vadimsh@chromium.orgcf6a5d22015-04-09 22:02:00 +0000172
173
174def extract_auth_config_from_options(options):
175 """Given OptionParser parsed options, extracts AuthConfig from it.
176
177 OptionParser should be populated with auth options by 'add_auth_options'.
178 """
179 return make_auth_config(
180 use_oauth2=options.use_oauth2,
181 save_cookies=False if options.use_oauth2 else options.save_cookies,
182 use_local_webserver=options.use_local_webserver,
vadimsh@chromium.org24daf9e2015-04-17 02:42:44 +0000183 webserver_port=options.auth_host_port,
184 refresh_token_json=options.auth_refresh_token_json)
vadimsh@chromium.orgcf6a5d22015-04-09 22:02:00 +0000185
186
187def auth_config_to_command_options(auth_config):
vadimsh@chromium.orgeed4df32015-04-10 21:30:20 +0000188 """AuthConfig -> list of strings with command line options.
189
190 Omits options that are set to default values.
191 """
vadimsh@chromium.orgcf6a5d22015-04-09 22:02:00 +0000192 if not auth_config:
193 return []
vadimsh@chromium.orgeed4df32015-04-10 21:30:20 +0000194 defaults = make_auth_config()
195 opts = []
196 if auth_config.use_oauth2 != defaults.use_oauth2:
197 opts.append('--oauth2' if auth_config.use_oauth2 else '--no-oauth2')
198 if auth_config.save_cookies != auth_config.save_cookies:
199 if not auth_config.save_cookies:
200 opts.append('--no-cookies')
201 if auth_config.use_local_webserver != defaults.use_local_webserver:
202 if not auth_config.use_local_webserver:
203 opts.append('--auth-no-local-webserver')
204 if auth_config.webserver_port != defaults.webserver_port:
205 opts.extend(['--auth-host-port', str(auth_config.webserver_port)])
vadimsh@chromium.org24daf9e2015-04-17 02:42:44 +0000206 if auth_config.refresh_token_json != defaults.refresh_token_json:
207 opts.extend([
208 '--auth-refresh-token-json', str(auth_config.refresh_token_json)])
vadimsh@chromium.orgcf6a5d22015-04-09 22:02:00 +0000209 return opts
vadimsh@chromium.orgeed4df32015-04-10 21:30:20 +0000210
211
212def get_authenticator_for_host(hostname, config):
213 """Returns Authenticator instance to access given host.
214
215 Args:
216 hostname: a naked hostname or http(s)://<hostname>[/] URL. Used to derive
217 a cache key for token cache.
218 config: AuthConfig instance.
219
220 Returns:
221 Authenticator object.
222 """
223 hostname = hostname.lower().rstrip('/')
224 # Append some scheme, otherwise urlparse puts hostname into parsed.path.
225 if '://' not in hostname:
226 hostname = 'https://' + hostname
seanmccullough@chromium.org3e4a5812015-06-11 17:48:47 +0000227 scopes = OAUTH_SCOPES
vadimsh@chromium.orgeed4df32015-04-10 21:30:20 +0000228 parsed = urlparse.urlparse(hostname)
seanmccullough@chromium.org3e4a5812015-06-11 17:48:47 +0000229 if parsed.netloc in ADDITIONAL_SCOPES:
230 scopes = "%s %s" % (scopes, ADDITIONAL_SCOPES[parsed.netloc])
231
vadimsh@chromium.orgeed4df32015-04-10 21:30:20 +0000232 if parsed.path or parsed.params or parsed.query or parsed.fragment:
233 raise AuthenticationError(
234 'Expecting a hostname or root host URL, got %s instead' % hostname)
seanmccullough@chromium.org3e4a5812015-06-11 17:48:47 +0000235 return Authenticator(parsed.netloc, config, scopes)
vadimsh@chromium.orgeed4df32015-04-10 21:30:20 +0000236
237
238class Authenticator(object):
239 """Object that knows how to refresh access tokens when needed.
240
241 Args:
242 token_cache_key: string key of a section of the token cache file to use
243 to keep the tokens. See hostname_to_token_cache_key.
244 config: AuthConfig object that holds authentication configuration.
245 """
246
seanmccullough@chromium.org3e4a5812015-06-11 17:48:47 +0000247 def __init__(self, token_cache_key, config, scopes):
vadimsh@chromium.orgeed4df32015-04-10 21:30:20 +0000248 assert isinstance(config, AuthConfig)
249 assert config.use_oauth2
250 self._access_token = None
251 self._config = config
252 self._lock = threading.Lock()
253 self._token_cache_key = token_cache_key
vadimsh@chromium.org24daf9e2015-04-17 02:42:44 +0000254 self._external_token = None
seanmccullough@chromium.org3e4a5812015-06-11 17:48:47 +0000255 self._scopes = scopes
vadimsh@chromium.org24daf9e2015-04-17 02:42:44 +0000256 if config.refresh_token_json:
257 self._external_token = _read_refresh_token_json(config.refresh_token_json)
vadimsh@chromium.orgcfbeecb2015-04-21 00:12:36 +0000258 logging.debug('Using auth config %r', config)
vadimsh@chromium.orgeed4df32015-04-10 21:30:20 +0000259
260 def login(self):
261 """Performs interactive login flow if necessary.
262
263 Raises:
264 AuthenticationError on error or if interrupted.
265 """
vadimsh@chromium.org24daf9e2015-04-17 02:42:44 +0000266 if self._external_token:
267 raise AuthenticationError(
268 'Can\'t run login flow when using --auth-refresh-token-json.')
vadimsh@chromium.orgeed4df32015-04-10 21:30:20 +0000269 return self.get_access_token(
270 force_refresh=True, allow_user_interaction=True)
271
272 def logout(self):
273 """Revokes the refresh token and deletes it from the cache.
274
vadimsh@chromium.org24daf9e2015-04-17 02:42:44 +0000275 Returns True if had some credentials cached.
vadimsh@chromium.orgeed4df32015-04-10 21:30:20 +0000276 """
vadimsh@chromium.orgeed4df32015-04-10 21:30:20 +0000277 with self._lock:
278 self._access_token = None
279 storage = self._get_storage()
280 credentials = storage.get()
vadimsh@chromium.org24daf9e2015-04-17 02:42:44 +0000281 had_creds = bool(credentials)
282 if credentials and credentials.refresh_token and credentials.revoke_uri:
283 try:
284 credentials.revoke(httplib2.Http())
285 except client.TokenRevokeError as e:
286 logging.warning('Failed to revoke refresh token: %s', e)
vadimsh@chromium.orgeed4df32015-04-10 21:30:20 +0000287 storage.delete()
vadimsh@chromium.org24daf9e2015-04-17 02:42:44 +0000288 return had_creds
vadimsh@chromium.orgeed4df32015-04-10 21:30:20 +0000289
290 def has_cached_credentials(self):
291 """Returns True if long term credentials (refresh token) are in cache.
292
293 Doesn't make network calls.
294
295 If returns False, get_access_token() later will ask for interactive login by
296 raising LoginRequiredError.
297
298 If returns True, most probably get_access_token() won't ask for interactive
299 login, though it is not guaranteed, since cached token can be already
300 revoked and there's no way to figure this out without actually trying to use
301 it.
302 """
303 with self._lock:
vadimsh@chromium.org24daf9e2015-04-17 02:42:44 +0000304 return bool(self._get_cached_credentials())
vadimsh@chromium.orgeed4df32015-04-10 21:30:20 +0000305
306 def get_access_token(self, force_refresh=False, allow_user_interaction=False):
307 """Returns AccessToken, refreshing it if necessary.
308
309 Args:
310 force_refresh: forcefully refresh access token even if it is not expired.
311 allow_user_interaction: True to enable blocking for user input if needed.
312
313 Raises:
314 AuthenticationError on error or if authentication flow was interrupted.
315 LoginRequiredError if user interaction is required, but
316 allow_user_interaction is False.
317 """
318 with self._lock:
319 if force_refresh:
vadimsh@chromium.orgcfbeecb2015-04-21 00:12:36 +0000320 logging.debug('Forcing access token refresh')
vadimsh@chromium.orgeed4df32015-04-10 21:30:20 +0000321 self._access_token = self._create_access_token(allow_user_interaction)
322 return self._access_token
323
324 # Load from on-disk cache on a first access.
325 if not self._access_token:
326 self._access_token = self._load_access_token()
327
328 # Refresh if expired or missing.
329 if not self._access_token or _needs_refresh(self._access_token):
330 # Maybe some other process already updated it, reload from the cache.
331 self._access_token = self._load_access_token()
332 # Nope, still expired, need to run the refresh flow.
333 if not self._access_token or _needs_refresh(self._access_token):
334 self._access_token = self._create_access_token(allow_user_interaction)
335
336 return self._access_token
337
338 def get_token_info(self):
339 """Returns a result of /oauth2/v2/tokeninfo call with token info."""
340 access_token = self.get_access_token()
341 resp, content = httplib2.Http().request(
342 uri='https://www.googleapis.com/oauth2/v2/tokeninfo?%s' % (
343 urllib.urlencode({'access_token': access_token.token})))
344 if resp.status == 200:
345 return json.loads(content)
346 raise AuthenticationError('Failed to fetch the token info: %r' % content)
347
348 def authorize(self, http):
349 """Monkey patches authentication logic of httplib2.Http instance.
350
351 The modified http.request method will add authentication headers to each
352 request and will refresh access_tokens when a 401 is received on a
353 request.
354
355 Args:
356 http: An instance of httplib2.Http.
357
358 Returns:
359 A modified instance of http that was passed in.
360 """
361 # Adapted from oauth2client.OAuth2Credentials.authorize.
362
363 request_orig = http.request
364
365 @functools.wraps(request_orig)
366 def new_request(
367 uri, method='GET', body=None, headers=None,
368 redirections=httplib2.DEFAULT_MAX_REDIRECTS,
369 connection_type=None):
370 headers = (headers or {}).copy()
vadimsh@chromium.orgafbb0192015-04-13 23:26:31 +0000371 headers['Authorization'] = 'Bearer %s' % self.get_access_token().token
vadimsh@chromium.orgeed4df32015-04-10 21:30:20 +0000372 resp, content = request_orig(
373 uri, method, body, headers, redirections, connection_type)
374 if resp.status in client.REFRESH_STATUS_CODES:
375 logging.info('Refreshing due to a %s', resp.status)
376 access_token = self.get_access_token(force_refresh=True)
vadimsh@chromium.orgafbb0192015-04-13 23:26:31 +0000377 headers['Authorization'] = 'Bearer %s' % access_token.token
vadimsh@chromium.orgeed4df32015-04-10 21:30:20 +0000378 return request_orig(
379 uri, method, body, headers, redirections, connection_type)
380 else:
381 return (resp, content)
382
383 http.request = new_request
384 return http
385
386 ## Private methods.
387
388 def _get_storage(self):
389 """Returns oauth2client.Storage with cached tokens."""
vadimsh@chromium.org24daf9e2015-04-17 02:42:44 +0000390 # Do not mix cache keys for different externally provided tokens.
391 if self._external_token:
392 token_hash = hashlib.sha1(self._external_token.refresh_token).hexdigest()
393 cache_key = '%s:refresh_tok:%s' % (self._token_cache_key, token_hash)
394 else:
395 cache_key = self._token_cache_key
vadimsh@chromium.org148f76f2015-04-21 01:44:13 +0000396 path = _get_token_cache_path()
397 logging.debug('Using token storage %r (cache key %r)', path, cache_key)
vadimsh@chromium.orgeed4df32015-04-10 21:30:20 +0000398 return multistore_file.get_credential_storage_custom_string_key(
vadimsh@chromium.org148f76f2015-04-21 01:44:13 +0000399 path, cache_key)
vadimsh@chromium.org24daf9e2015-04-17 02:42:44 +0000400
401 def _get_cached_credentials(self):
402 """Returns oauth2client.Credentials loaded from storage."""
403 storage = self._get_storage()
404 credentials = storage.get()
405
vadimsh@chromium.orgcfbeecb2015-04-21 00:12:36 +0000406 if not credentials:
407 logging.debug('No cached token')
408 else:
409 _log_credentials_info('cached token', credentials)
410
vadimsh@chromium.org24daf9e2015-04-17 02:42:44 +0000411 # Is using --auth-refresh-token-json?
412 if self._external_token:
413 # Cached credentials are valid and match external token -> use them. It is
414 # important to reuse credentials from the storage because they contain
415 # cached access token.
416 valid = (
417 credentials and not credentials.invalid and
418 credentials.refresh_token == self._external_token.refresh_token and
419 credentials.client_id == self._external_token.client_id and
420 credentials.client_secret == self._external_token.client_secret)
421 if valid:
vadimsh@chromium.orgcfbeecb2015-04-21 00:12:36 +0000422 logging.debug('Cached credentials match external refresh token')
vadimsh@chromium.org24daf9e2015-04-17 02:42:44 +0000423 return credentials
424 # Construct new credentials from externally provided refresh token,
425 # associate them with cache storage (so that access_token will be placed
426 # in the cache later too).
vadimsh@chromium.orgcfbeecb2015-04-21 00:12:36 +0000427 logging.debug('Putting external refresh token into the cache')
vadimsh@chromium.org24daf9e2015-04-17 02:42:44 +0000428 credentials = client.OAuth2Credentials(
429 access_token=None,
430 client_id=self._external_token.client_id,
431 client_secret=self._external_token.client_secret,
432 refresh_token=self._external_token.refresh_token,
433 token_expiry=None,
434 token_uri='https://accounts.google.com/o/oauth2/token',
435 user_agent=None,
436 revoke_uri=None)
437 credentials.set_store(storage)
438 storage.put(credentials)
439 return credentials
440
441 # Not using external refresh token -> return whatever is cached.
442 return credentials if (credentials and not credentials.invalid) else None
vadimsh@chromium.orgeed4df32015-04-10 21:30:20 +0000443
444 def _load_access_token(self):
445 """Returns cached AccessToken if it is not expired yet."""
vadimsh@chromium.orgcfbeecb2015-04-21 00:12:36 +0000446 logging.debug('Reloading access token from cache')
vadimsh@chromium.org24daf9e2015-04-17 02:42:44 +0000447 creds = self._get_cached_credentials()
448 if not creds or not creds.access_token or creds.access_token_expired:
vadimsh@chromium.orgcfbeecb2015-04-21 00:12:36 +0000449 logging.debug('Access token is missing or expired')
vadimsh@chromium.orgeed4df32015-04-10 21:30:20 +0000450 return None
vadimsh@chromium.org24daf9e2015-04-17 02:42:44 +0000451 return AccessToken(str(creds.access_token), creds.token_expiry)
vadimsh@chromium.orgeed4df32015-04-10 21:30:20 +0000452
453 def _create_access_token(self, allow_user_interaction=False):
454 """Mints and caches a new access token, launching OAuth2 dance if necessary.
455
456 Uses cached refresh token, if present. In that case user interaction is not
457 required and function will finish quietly. Otherwise it will launch 3-legged
458 OAuth2 flow, that needs user interaction.
459
460 Args:
461 allow_user_interaction: if True, allow interaction with the user (e.g.
462 reading standard input, or launching a browser).
463
464 Returns:
465 AccessToken.
466
467 Raises:
468 AuthenticationError on error or if authentication flow was interrupted.
469 LoginRequiredError if user interaction is required, but
470 allow_user_interaction is False.
471 """
vadimsh@chromium.orgcfbeecb2015-04-21 00:12:36 +0000472 logging.debug(
473 'Making new access token (allow_user_interaction=%r)',
474 allow_user_interaction)
vadimsh@chromium.org24daf9e2015-04-17 02:42:44 +0000475 credentials = self._get_cached_credentials()
vadimsh@chromium.orgeed4df32015-04-10 21:30:20 +0000476
477 # 3-legged flow with (perhaps cached) refresh token.
vadimsh@chromium.orgeed4df32015-04-10 21:30:20 +0000478 refreshed = False
479 if credentials and not credentials.invalid:
480 try:
vadimsh@chromium.orgcfbeecb2015-04-21 00:12:36 +0000481 logging.debug('Attempting to refresh access_token')
vadimsh@chromium.orgeed4df32015-04-10 21:30:20 +0000482 credentials.refresh(httplib2.Http())
vadimsh@chromium.orgcfbeecb2015-04-21 00:12:36 +0000483 _log_credentials_info('refreshed token', credentials)
vadimsh@chromium.orgeed4df32015-04-10 21:30:20 +0000484 refreshed = True
485 except client.Error as err:
486 logging.warning(
vadimsh@chromium.org24daf9e2015-04-17 02:42:44 +0000487 'OAuth error during access token refresh (%s). '
vadimsh@chromium.orgeed4df32015-04-10 21:30:20 +0000488 'Attempting a full authentication flow.', err)
489
490 # Refresh token is missing or invalid, go through the full flow.
491 if not refreshed:
vadimsh@chromium.org24daf9e2015-04-17 02:42:44 +0000492 # Can't refresh externally provided token.
493 if self._external_token:
494 raise AuthenticationError(
495 'Token provided via --auth-refresh-token-json is no longer valid.')
vadimsh@chromium.orgeed4df32015-04-10 21:30:20 +0000496 if not allow_user_interaction:
vadimsh@chromium.orgcfbeecb2015-04-21 00:12:36 +0000497 logging.debug('Requesting user to login')
vadimsh@chromium.orgeed4df32015-04-10 21:30:20 +0000498 raise LoginRequiredError(self._token_cache_key)
vadimsh@chromium.orgcfbeecb2015-04-21 00:12:36 +0000499 logging.debug('Launching OAuth browser flow')
seanmccullough@chromium.org3e4a5812015-06-11 17:48:47 +0000500 credentials = _run_oauth_dance(self._config, self._scopes)
vadimsh@chromium.orgcfbeecb2015-04-21 00:12:36 +0000501 _log_credentials_info('new token', credentials)
vadimsh@chromium.orgeed4df32015-04-10 21:30:20 +0000502
503 logging.info(
504 'OAuth access_token refreshed. Expires in %s.',
505 credentials.token_expiry - datetime.datetime.utcnow())
vadimsh@chromium.org24daf9e2015-04-17 02:42:44 +0000506 storage = self._get_storage()
vadimsh@chromium.orgeed4df32015-04-10 21:30:20 +0000507 credentials.set_store(storage)
508 storage.put(credentials)
vadimsh@chromium.orgafbb0192015-04-13 23:26:31 +0000509 return AccessToken(str(credentials.access_token), credentials.token_expiry)
vadimsh@chromium.orgeed4df32015-04-10 21:30:20 +0000510
511
512## Private functions.
513
514
vadimsh@chromium.org148f76f2015-04-21 01:44:13 +0000515def _get_token_cache_path():
516 # On non Win just use HOME.
517 if sys.platform != 'win32':
518 return os.path.join(os.path.expanduser('~'), OAUTH_TOKENS_CACHE)
519 # Prefer USERPROFILE over HOME, since HOME is overridden in
520 # git-..._bin/cmd/git.cmd to point to depot_tools. depot-tools-auth.py script
521 # (and all other scripts) doesn't use this override and thus uses another
522 # value for HOME. git.cmd doesn't touch USERPROFILE though, and usually
523 # USERPROFILE == HOME on Windows.
524 if 'USERPROFILE' in os.environ:
525 return os.path.join(os.environ['USERPROFILE'], OAUTH_TOKENS_CACHE)
526 return os.path.join(os.path.expanduser('~'), OAUTH_TOKENS_CACHE)
527
528
vadimsh@chromium.orgeed4df32015-04-10 21:30:20 +0000529def _is_headless():
530 """True if machine doesn't seem to have a display."""
531 return sys.platform == 'linux2' and not os.environ.get('DISPLAY')
532
533
vadimsh@chromium.org24daf9e2015-04-17 02:42:44 +0000534def _read_refresh_token_json(path):
535 """Returns RefreshToken by reading it from the JSON file."""
536 try:
537 with open(path, 'r') as f:
538 data = json.load(f)
539 return RefreshToken(
540 client_id=str(data.get('client_id', OAUTH_CLIENT_ID)),
541 client_secret=str(data.get('client_secret', OAUTH_CLIENT_SECRET)),
542 refresh_token=str(data['refresh_token']))
543 except (IOError, ValueError) as e:
544 raise AuthenticationError(
545 'Failed to read refresh token from %s: %s' % (path, e))
546 except KeyError as e:
547 raise AuthenticationError(
548 'Failed to read refresh token from %s: missing key %s' % (path, e))
549
550
vadimsh@chromium.orgeed4df32015-04-10 21:30:20 +0000551def _needs_refresh(access_token):
552 """True if AccessToken should be refreshed."""
553 if access_token.expires_at is not None:
554 # Allow 5 min of clock skew between client and backend.
555 now = datetime.datetime.utcnow() + datetime.timedelta(seconds=300)
556 return now >= access_token.expires_at
557 # Token without expiration time never expires.
558 return False
559
560
vadimsh@chromium.orgcfbeecb2015-04-21 00:12:36 +0000561def _log_credentials_info(title, credentials):
562 """Dumps (non sensitive) part of client.Credentials object to debug log."""
563 if credentials:
564 logging.debug('%s info: %r', title, {
565 'access_token_expired': credentials.access_token_expired,
566 'has_access_token': bool(credentials.access_token),
567 'invalid': credentials.invalid,
568 'utcnow': datetime.datetime.utcnow(),
569 'token_expiry': credentials.token_expiry,
570 })
571
572
seanmccullough@chromium.org3e4a5812015-06-11 17:48:47 +0000573def _run_oauth_dance(config, scopes):
vadimsh@chromium.orgeed4df32015-04-10 21:30:20 +0000574 """Perform full 3-legged OAuth2 flow with the browser.
575
576 Returns:
577 oauth2client.Credentials.
578
579 Raises:
580 AuthenticationError on errors.
581 """
582 flow = client.OAuth2WebServerFlow(
583 OAUTH_CLIENT_ID,
584 OAUTH_CLIENT_SECRET,
seanmccullough@chromium.org3e4a5812015-06-11 17:48:47 +0000585 scopes,
vadimsh@chromium.orgeed4df32015-04-10 21:30:20 +0000586 approval_prompt='force')
587
588 use_local_webserver = config.use_local_webserver
589 port = config.webserver_port
590 if config.use_local_webserver:
591 success = False
592 try:
593 httpd = _ClientRedirectServer(('localhost', port), _ClientRedirectHandler)
594 except socket.error:
595 pass
596 else:
597 success = True
598 use_local_webserver = success
599 if not success:
600 print(
601 'Failed to start a local webserver listening on port %d.\n'
602 'Please check your firewall settings and locally running programs that '
603 'may be blocking or using those ports.\n\n'
604 'Falling back to --auth-no-local-webserver and continuing with '
605 'authentication.\n' % port)
606
607 if use_local_webserver:
608 oauth_callback = 'http://localhost:%s/' % port
609 else:
610 oauth_callback = client.OOB_CALLBACK_URN
611 flow.redirect_uri = oauth_callback
612 authorize_url = flow.step1_get_authorize_url()
613
614 if use_local_webserver:
615 webbrowser.open(authorize_url, new=1, autoraise=True)
616 print(
617 'Your browser has been opened to visit:\n\n'
618 ' %s\n\n'
619 'If your browser is on a different machine then exit and re-run this '
620 'application with the command-line parameter\n\n'
621 ' --auth-no-local-webserver\n' % authorize_url)
622 else:
623 print(
624 'Go to the following link in your browser:\n\n'
625 ' %s\n' % authorize_url)
626
627 try:
628 code = None
629 if use_local_webserver:
630 httpd.handle_request()
631 if 'error' in httpd.query_params:
632 raise AuthenticationError(
633 'Authentication request was rejected: %s' %
634 httpd.query_params['error'])
635 if 'code' not in httpd.query_params:
636 raise AuthenticationError(
637 'Failed to find "code" in the query parameters of the redirect.\n'
638 'Try running with --auth-no-local-webserver.')
639 code = httpd.query_params['code']
640 else:
641 code = raw_input('Enter verification code: ').strip()
642 except KeyboardInterrupt:
643 raise AuthenticationError('Authentication was canceled.')
644
645 try:
646 return flow.step2_exchange(code)
647 except client.FlowExchangeError as e:
648 raise AuthenticationError('Authentication has failed: %s' % e)
649
650
651class _ClientRedirectServer(BaseHTTPServer.HTTPServer):
652 """A server to handle OAuth 2.0 redirects back to localhost.
653
654 Waits for a single request and parses the query parameters
655 into query_params and then stops serving.
656 """
657 query_params = {}
658
659
660class _ClientRedirectHandler(BaseHTTPServer.BaseHTTPRequestHandler):
661 """A handler for OAuth 2.0 redirects back to localhost.
662
663 Waits for a single request and parses the query parameters
664 into the servers query_params and then stops serving.
665 """
666
667 def do_GET(self):
668 """Handle a GET request.
669
670 Parses the query parameters and prints a message
671 if the flow has completed. Note that we can't detect
672 if an error occurred.
673 """
674 self.send_response(200)
675 self.send_header('Content-type', 'text/html')
676 self.end_headers()
677 query = self.path.split('?', 1)[-1]
678 query = dict(urlparse.parse_qsl(query))
679 self.server.query_params = query
680 self.wfile.write('<html><head><title>Authentication Status</title></head>')
681 self.wfile.write('<body><p>The authentication flow has completed.</p>')
682 self.wfile.write('</body></html>')
683
684 def log_message(self, _format, *args):
685 """Do not log messages to stdout while running as command line program."""