blob: 3c1099cf024e36022f299839770df6ae427ef4d1 [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
51# Path to a file with cached OAuth2 credentials used by default. It should be
52# a safe location accessible only to a current user: knowing content of this
53# file is roughly equivalent to knowing account password. Single file can hold
54# multiple independent tokens identified by token_cache_key (see Authenticator).
55OAUTH_TOKENS_CACHE = os.path.join(
56 os.path.expanduser('~'), '.depot_tools_oauth2_tokens')
vadimsh@chromium.orgcf6a5d22015-04-09 22:02:00 +000057
58
59# Authentication configuration extracted from command line options.
60# See doc string for 'make_auth_config' for meaning of fields.
61AuthConfig = collections.namedtuple('AuthConfig', [
62 'use_oauth2', # deprecated, will be always True
63 'save_cookies', # deprecated, will be removed
64 'use_local_webserver',
65 'webserver_port',
vadimsh@chromium.org24daf9e2015-04-17 02:42:44 +000066 'refresh_token_json',
vadimsh@chromium.orgcf6a5d22015-04-09 22:02:00 +000067])
68
69
vadimsh@chromium.orgeed4df32015-04-10 21:30:20 +000070# OAuth access token with its expiration time (UTC datetime or None if unknown).
71AccessToken = collections.namedtuple('AccessToken', [
72 'token',
73 'expires_at',
74])
75
76
vadimsh@chromium.org24daf9e2015-04-17 02:42:44 +000077# Refresh token passed via --auth-refresh-token-json.
78RefreshToken = collections.namedtuple('RefreshToken', [
79 'client_id',
80 'client_secret',
81 'refresh_token',
82])
83
84
vadimsh@chromium.orgeed4df32015-04-10 21:30:20 +000085class AuthenticationError(Exception):
86 """Raised on errors related to authentication."""
87
88
89class LoginRequiredError(AuthenticationError):
90 """Interaction with the user is required to authenticate."""
91
92 def __init__(self, token_cache_key):
93 # HACK(vadimsh): It is assumed here that the token cache key is a hostname.
94 msg = (
95 'You are not logged in. Please login first by running:\n'
96 ' depot-tools-auth login %s' % token_cache_key)
97 super(LoginRequiredError, self).__init__(msg)
98
99
vadimsh@chromium.orgcf6a5d22015-04-09 22:02:00 +0000100def make_auth_config(
101 use_oauth2=None,
102 save_cookies=None,
103 use_local_webserver=None,
vadimsh@chromium.org24daf9e2015-04-17 02:42:44 +0000104 webserver_port=None,
105 refresh_token_json=None):
vadimsh@chromium.orgcf6a5d22015-04-09 22:02:00 +0000106 """Returns new instance of AuthConfig.
107
108 If some config option is None, it will be set to a reasonable default value.
109 This function also acts as an authoritative place for default values of
110 corresponding command line options.
111 """
112 default = lambda val, d: val if val is not None else d
113 return AuthConfig(
vadimsh@chromium.org19f3fe62015-04-20 17:03:10 +0000114 default(use_oauth2, True),
vadimsh@chromium.orgcf6a5d22015-04-09 22:02:00 +0000115 default(save_cookies, True),
vadimsh@chromium.orgeed4df32015-04-10 21:30:20 +0000116 default(use_local_webserver, not _is_headless()),
vadimsh@chromium.org24daf9e2015-04-17 02:42:44 +0000117 default(webserver_port, 8090),
118 default(refresh_token_json, ''))
vadimsh@chromium.orgcf6a5d22015-04-09 22:02:00 +0000119
120
vadimsh@chromium.orgeed4df32015-04-10 21:30:20 +0000121def add_auth_options(parser, default_config=None):
vadimsh@chromium.orgcf6a5d22015-04-09 22:02:00 +0000122 """Appends OAuth related options to OptionParser."""
vadimsh@chromium.orgeed4df32015-04-10 21:30:20 +0000123 default_config = default_config or make_auth_config()
vadimsh@chromium.orgcf6a5d22015-04-09 22:02:00 +0000124 parser.auth_group = optparse.OptionGroup(parser, 'Auth options')
vadimsh@chromium.orgeed4df32015-04-10 21:30:20 +0000125 parser.add_option_group(parser.auth_group)
126
127 # OAuth2 vs password switch.
128 auth_default = 'use OAuth2' if default_config.use_oauth2 else 'use password'
vadimsh@chromium.orgcf6a5d22015-04-09 22:02:00 +0000129 parser.auth_group.add_option(
130 '--oauth2',
131 action='store_true',
132 dest='use_oauth2',
133 default=default_config.use_oauth2,
vadimsh@chromium.orgeed4df32015-04-10 21:30:20 +0000134 help='Use OAuth 2.0 instead of a password. [default: %s]' % auth_default)
vadimsh@chromium.orgcf6a5d22015-04-09 22:02:00 +0000135 parser.auth_group.add_option(
136 '--no-oauth2',
137 action='store_false',
138 dest='use_oauth2',
139 default=default_config.use_oauth2,
vadimsh@chromium.orgeed4df32015-04-10 21:30:20 +0000140 help='Use password instead of OAuth 2.0. [default: %s]' % auth_default)
141
142 # Password related options, deprecated.
vadimsh@chromium.orgcf6a5d22015-04-09 22:02:00 +0000143 parser.auth_group.add_option(
144 '--no-cookies',
145 action='store_false',
146 dest='save_cookies',
147 default=default_config.save_cookies,
148 help='Do not save authentication cookies to local disk.')
vadimsh@chromium.orgeed4df32015-04-10 21:30:20 +0000149
150 # OAuth2 related options.
vadimsh@chromium.orgcf6a5d22015-04-09 22:02:00 +0000151 parser.auth_group.add_option(
152 '--auth-no-local-webserver',
153 action='store_false',
154 dest='use_local_webserver',
155 default=default_config.use_local_webserver,
156 help='Do not run a local web server when performing OAuth2 login flow.')
157 parser.auth_group.add_option(
158 '--auth-host-port',
159 type=int,
160 default=default_config.webserver_port,
161 help='Port a local web server should listen on. Used only if '
162 '--auth-no-local-webserver is not set. [default: %default]')
vadimsh@chromium.org24daf9e2015-04-17 02:42:44 +0000163 parser.auth_group.add_option(
164 '--auth-refresh-token-json',
165 default=default_config.refresh_token_json,
166 help='Path to a JSON file with role account refresh token to use.')
vadimsh@chromium.orgcf6a5d22015-04-09 22:02:00 +0000167
168
169def extract_auth_config_from_options(options):
170 """Given OptionParser parsed options, extracts AuthConfig from it.
171
172 OptionParser should be populated with auth options by 'add_auth_options'.
173 """
174 return make_auth_config(
175 use_oauth2=options.use_oauth2,
176 save_cookies=False if options.use_oauth2 else options.save_cookies,
177 use_local_webserver=options.use_local_webserver,
vadimsh@chromium.org24daf9e2015-04-17 02:42:44 +0000178 webserver_port=options.auth_host_port,
179 refresh_token_json=options.auth_refresh_token_json)
vadimsh@chromium.orgcf6a5d22015-04-09 22:02:00 +0000180
181
182def auth_config_to_command_options(auth_config):
vadimsh@chromium.orgeed4df32015-04-10 21:30:20 +0000183 """AuthConfig -> list of strings with command line options.
184
185 Omits options that are set to default values.
186 """
vadimsh@chromium.orgcf6a5d22015-04-09 22:02:00 +0000187 if not auth_config:
188 return []
vadimsh@chromium.orgeed4df32015-04-10 21:30:20 +0000189 defaults = make_auth_config()
190 opts = []
191 if auth_config.use_oauth2 != defaults.use_oauth2:
192 opts.append('--oauth2' if auth_config.use_oauth2 else '--no-oauth2')
193 if auth_config.save_cookies != auth_config.save_cookies:
194 if not auth_config.save_cookies:
195 opts.append('--no-cookies')
196 if auth_config.use_local_webserver != defaults.use_local_webserver:
197 if not auth_config.use_local_webserver:
198 opts.append('--auth-no-local-webserver')
199 if auth_config.webserver_port != defaults.webserver_port:
200 opts.extend(['--auth-host-port', str(auth_config.webserver_port)])
vadimsh@chromium.org24daf9e2015-04-17 02:42:44 +0000201 if auth_config.refresh_token_json != defaults.refresh_token_json:
202 opts.extend([
203 '--auth-refresh-token-json', str(auth_config.refresh_token_json)])
vadimsh@chromium.orgcf6a5d22015-04-09 22:02:00 +0000204 return opts
vadimsh@chromium.orgeed4df32015-04-10 21:30:20 +0000205
206
207def get_authenticator_for_host(hostname, config):
208 """Returns Authenticator instance to access given host.
209
210 Args:
211 hostname: a naked hostname or http(s)://<hostname>[/] URL. Used to derive
212 a cache key for token cache.
213 config: AuthConfig instance.
214
215 Returns:
216 Authenticator object.
217 """
218 hostname = hostname.lower().rstrip('/')
219 # Append some scheme, otherwise urlparse puts hostname into parsed.path.
220 if '://' not in hostname:
221 hostname = 'https://' + hostname
222 parsed = urlparse.urlparse(hostname)
223 if parsed.path or parsed.params or parsed.query or parsed.fragment:
224 raise AuthenticationError(
225 'Expecting a hostname or root host URL, got %s instead' % hostname)
226 return Authenticator(parsed.netloc, config)
227
228
229class Authenticator(object):
230 """Object that knows how to refresh access tokens when needed.
231
232 Args:
233 token_cache_key: string key of a section of the token cache file to use
234 to keep the tokens. See hostname_to_token_cache_key.
235 config: AuthConfig object that holds authentication configuration.
236 """
237
238 def __init__(self, token_cache_key, config):
239 assert isinstance(config, AuthConfig)
240 assert config.use_oauth2
241 self._access_token = None
242 self._config = config
243 self._lock = threading.Lock()
244 self._token_cache_key = token_cache_key
vadimsh@chromium.org24daf9e2015-04-17 02:42:44 +0000245 self._external_token = None
246 if config.refresh_token_json:
247 self._external_token = _read_refresh_token_json(config.refresh_token_json)
vadimsh@chromium.orgcfbeecb2015-04-21 00:12:36 +0000248 logging.debug('Using auth config %r', config)
vadimsh@chromium.orgeed4df32015-04-10 21:30:20 +0000249
250 def login(self):
251 """Performs interactive login flow if necessary.
252
253 Raises:
254 AuthenticationError on error or if interrupted.
255 """
vadimsh@chromium.org24daf9e2015-04-17 02:42:44 +0000256 if self._external_token:
257 raise AuthenticationError(
258 'Can\'t run login flow when using --auth-refresh-token-json.')
vadimsh@chromium.orgeed4df32015-04-10 21:30:20 +0000259 return self.get_access_token(
260 force_refresh=True, allow_user_interaction=True)
261
262 def logout(self):
263 """Revokes the refresh token and deletes it from the cache.
264
vadimsh@chromium.org24daf9e2015-04-17 02:42:44 +0000265 Returns True if had some credentials cached.
vadimsh@chromium.orgeed4df32015-04-10 21:30:20 +0000266 """
vadimsh@chromium.orgeed4df32015-04-10 21:30:20 +0000267 with self._lock:
268 self._access_token = None
269 storage = self._get_storage()
270 credentials = storage.get()
vadimsh@chromium.org24daf9e2015-04-17 02:42:44 +0000271 had_creds = bool(credentials)
272 if credentials and credentials.refresh_token and credentials.revoke_uri:
273 try:
274 credentials.revoke(httplib2.Http())
275 except client.TokenRevokeError as e:
276 logging.warning('Failed to revoke refresh token: %s', e)
vadimsh@chromium.orgeed4df32015-04-10 21:30:20 +0000277 storage.delete()
vadimsh@chromium.org24daf9e2015-04-17 02:42:44 +0000278 return had_creds
vadimsh@chromium.orgeed4df32015-04-10 21:30:20 +0000279
280 def has_cached_credentials(self):
281 """Returns True if long term credentials (refresh token) are in cache.
282
283 Doesn't make network calls.
284
285 If returns False, get_access_token() later will ask for interactive login by
286 raising LoginRequiredError.
287
288 If returns True, most probably get_access_token() won't ask for interactive
289 login, though it is not guaranteed, since cached token can be already
290 revoked and there's no way to figure this out without actually trying to use
291 it.
292 """
293 with self._lock:
vadimsh@chromium.org24daf9e2015-04-17 02:42:44 +0000294 return bool(self._get_cached_credentials())
vadimsh@chromium.orgeed4df32015-04-10 21:30:20 +0000295
296 def get_access_token(self, force_refresh=False, allow_user_interaction=False):
297 """Returns AccessToken, refreshing it if necessary.
298
299 Args:
300 force_refresh: forcefully refresh access token even if it is not expired.
301 allow_user_interaction: True to enable blocking for user input if needed.
302
303 Raises:
304 AuthenticationError on error or if authentication flow was interrupted.
305 LoginRequiredError if user interaction is required, but
306 allow_user_interaction is False.
307 """
308 with self._lock:
309 if force_refresh:
vadimsh@chromium.orgcfbeecb2015-04-21 00:12:36 +0000310 logging.debug('Forcing access token refresh')
vadimsh@chromium.orgeed4df32015-04-10 21:30:20 +0000311 self._access_token = self._create_access_token(allow_user_interaction)
312 return self._access_token
313
314 # Load from on-disk cache on a first access.
315 if not self._access_token:
316 self._access_token = self._load_access_token()
317
318 # Refresh if expired or missing.
319 if not self._access_token or _needs_refresh(self._access_token):
320 # Maybe some other process already updated it, reload from the cache.
321 self._access_token = self._load_access_token()
322 # Nope, still expired, need to run the refresh flow.
323 if not self._access_token or _needs_refresh(self._access_token):
324 self._access_token = self._create_access_token(allow_user_interaction)
325
326 return self._access_token
327
328 def get_token_info(self):
329 """Returns a result of /oauth2/v2/tokeninfo call with token info."""
330 access_token = self.get_access_token()
331 resp, content = httplib2.Http().request(
332 uri='https://www.googleapis.com/oauth2/v2/tokeninfo?%s' % (
333 urllib.urlencode({'access_token': access_token.token})))
334 if resp.status == 200:
335 return json.loads(content)
336 raise AuthenticationError('Failed to fetch the token info: %r' % content)
337
338 def authorize(self, http):
339 """Monkey patches authentication logic of httplib2.Http instance.
340
341 The modified http.request method will add authentication headers to each
342 request and will refresh access_tokens when a 401 is received on a
343 request.
344
345 Args:
346 http: An instance of httplib2.Http.
347
348 Returns:
349 A modified instance of http that was passed in.
350 """
351 # Adapted from oauth2client.OAuth2Credentials.authorize.
352
353 request_orig = http.request
354
355 @functools.wraps(request_orig)
356 def new_request(
357 uri, method='GET', body=None, headers=None,
358 redirections=httplib2.DEFAULT_MAX_REDIRECTS,
359 connection_type=None):
360 headers = (headers or {}).copy()
vadimsh@chromium.orgafbb0192015-04-13 23:26:31 +0000361 headers['Authorization'] = 'Bearer %s' % self.get_access_token().token
vadimsh@chromium.orgeed4df32015-04-10 21:30:20 +0000362 resp, content = request_orig(
363 uri, method, body, headers, redirections, connection_type)
364 if resp.status in client.REFRESH_STATUS_CODES:
365 logging.info('Refreshing due to a %s', resp.status)
366 access_token = self.get_access_token(force_refresh=True)
vadimsh@chromium.orgafbb0192015-04-13 23:26:31 +0000367 headers['Authorization'] = 'Bearer %s' % access_token.token
vadimsh@chromium.orgeed4df32015-04-10 21:30:20 +0000368 return request_orig(
369 uri, method, body, headers, redirections, connection_type)
370 else:
371 return (resp, content)
372
373 http.request = new_request
374 return http
375
376 ## Private methods.
377
378 def _get_storage(self):
379 """Returns oauth2client.Storage with cached tokens."""
vadimsh@chromium.org24daf9e2015-04-17 02:42:44 +0000380 # Do not mix cache keys for different externally provided tokens.
381 if self._external_token:
382 token_hash = hashlib.sha1(self._external_token.refresh_token).hexdigest()
383 cache_key = '%s:refresh_tok:%s' % (self._token_cache_key, token_hash)
384 else:
385 cache_key = self._token_cache_key
vadimsh@chromium.orgcfbeecb2015-04-21 00:12:36 +0000386 logging.debug(
387 'Using token storage %r (cache key %r)', OAUTH_TOKENS_CACHE, cache_key)
vadimsh@chromium.orgeed4df32015-04-10 21:30:20 +0000388 return multistore_file.get_credential_storage_custom_string_key(
vadimsh@chromium.org24daf9e2015-04-17 02:42:44 +0000389 OAUTH_TOKENS_CACHE, cache_key)
390
391 def _get_cached_credentials(self):
392 """Returns oauth2client.Credentials loaded from storage."""
393 storage = self._get_storage()
394 credentials = storage.get()
395
vadimsh@chromium.orgcfbeecb2015-04-21 00:12:36 +0000396 if not credentials:
397 logging.debug('No cached token')
398 else:
399 _log_credentials_info('cached token', credentials)
400
vadimsh@chromium.org24daf9e2015-04-17 02:42:44 +0000401 # Is using --auth-refresh-token-json?
402 if self._external_token:
403 # Cached credentials are valid and match external token -> use them. It is
404 # important to reuse credentials from the storage because they contain
405 # cached access token.
406 valid = (
407 credentials and not credentials.invalid and
408 credentials.refresh_token == self._external_token.refresh_token and
409 credentials.client_id == self._external_token.client_id and
410 credentials.client_secret == self._external_token.client_secret)
411 if valid:
vadimsh@chromium.orgcfbeecb2015-04-21 00:12:36 +0000412 logging.debug('Cached credentials match external refresh token')
vadimsh@chromium.org24daf9e2015-04-17 02:42:44 +0000413 return credentials
414 # Construct new credentials from externally provided refresh token,
415 # associate them with cache storage (so that access_token will be placed
416 # in the cache later too).
vadimsh@chromium.orgcfbeecb2015-04-21 00:12:36 +0000417 logging.debug('Putting external refresh token into the cache')
vadimsh@chromium.org24daf9e2015-04-17 02:42:44 +0000418 credentials = client.OAuth2Credentials(
419 access_token=None,
420 client_id=self._external_token.client_id,
421 client_secret=self._external_token.client_secret,
422 refresh_token=self._external_token.refresh_token,
423 token_expiry=None,
424 token_uri='https://accounts.google.com/o/oauth2/token',
425 user_agent=None,
426 revoke_uri=None)
427 credentials.set_store(storage)
428 storage.put(credentials)
429 return credentials
430
431 # Not using external refresh token -> return whatever is cached.
432 return credentials if (credentials and not credentials.invalid) else None
vadimsh@chromium.orgeed4df32015-04-10 21:30:20 +0000433
434 def _load_access_token(self):
435 """Returns cached AccessToken if it is not expired yet."""
vadimsh@chromium.orgcfbeecb2015-04-21 00:12:36 +0000436 logging.debug('Reloading access token from cache')
vadimsh@chromium.org24daf9e2015-04-17 02:42:44 +0000437 creds = self._get_cached_credentials()
438 if not creds or not creds.access_token or creds.access_token_expired:
vadimsh@chromium.orgcfbeecb2015-04-21 00:12:36 +0000439 logging.debug('Access token is missing or expired')
vadimsh@chromium.orgeed4df32015-04-10 21:30:20 +0000440 return None
vadimsh@chromium.org24daf9e2015-04-17 02:42:44 +0000441 return AccessToken(str(creds.access_token), creds.token_expiry)
vadimsh@chromium.orgeed4df32015-04-10 21:30:20 +0000442
443 def _create_access_token(self, allow_user_interaction=False):
444 """Mints and caches a new access token, launching OAuth2 dance if necessary.
445
446 Uses cached refresh token, if present. In that case user interaction is not
447 required and function will finish quietly. Otherwise it will launch 3-legged
448 OAuth2 flow, that needs user interaction.
449
450 Args:
451 allow_user_interaction: if True, allow interaction with the user (e.g.
452 reading standard input, or launching a browser).
453
454 Returns:
455 AccessToken.
456
457 Raises:
458 AuthenticationError on error or if authentication flow was interrupted.
459 LoginRequiredError if user interaction is required, but
460 allow_user_interaction is False.
461 """
vadimsh@chromium.orgcfbeecb2015-04-21 00:12:36 +0000462 logging.debug(
463 'Making new access token (allow_user_interaction=%r)',
464 allow_user_interaction)
vadimsh@chromium.org24daf9e2015-04-17 02:42:44 +0000465 credentials = self._get_cached_credentials()
vadimsh@chromium.orgeed4df32015-04-10 21:30:20 +0000466
467 # 3-legged flow with (perhaps cached) refresh token.
vadimsh@chromium.orgeed4df32015-04-10 21:30:20 +0000468 refreshed = False
469 if credentials and not credentials.invalid:
470 try:
vadimsh@chromium.orgcfbeecb2015-04-21 00:12:36 +0000471 logging.debug('Attempting to refresh access_token')
vadimsh@chromium.orgeed4df32015-04-10 21:30:20 +0000472 credentials.refresh(httplib2.Http())
vadimsh@chromium.orgcfbeecb2015-04-21 00:12:36 +0000473 _log_credentials_info('refreshed token', credentials)
vadimsh@chromium.orgeed4df32015-04-10 21:30:20 +0000474 refreshed = True
475 except client.Error as err:
476 logging.warning(
vadimsh@chromium.org24daf9e2015-04-17 02:42:44 +0000477 'OAuth error during access token refresh (%s). '
vadimsh@chromium.orgeed4df32015-04-10 21:30:20 +0000478 'Attempting a full authentication flow.', err)
479
480 # Refresh token is missing or invalid, go through the full flow.
481 if not refreshed:
vadimsh@chromium.org24daf9e2015-04-17 02:42:44 +0000482 # Can't refresh externally provided token.
483 if self._external_token:
484 raise AuthenticationError(
485 'Token provided via --auth-refresh-token-json is no longer valid.')
vadimsh@chromium.orgeed4df32015-04-10 21:30:20 +0000486 if not allow_user_interaction:
vadimsh@chromium.orgcfbeecb2015-04-21 00:12:36 +0000487 logging.debug('Requesting user to login')
vadimsh@chromium.orgeed4df32015-04-10 21:30:20 +0000488 raise LoginRequiredError(self._token_cache_key)
vadimsh@chromium.orgcfbeecb2015-04-21 00:12:36 +0000489 logging.debug('Launching OAuth browser flow')
vadimsh@chromium.orgeed4df32015-04-10 21:30:20 +0000490 credentials = _run_oauth_dance(self._config)
vadimsh@chromium.orgcfbeecb2015-04-21 00:12:36 +0000491 _log_credentials_info('new token', credentials)
vadimsh@chromium.orgeed4df32015-04-10 21:30:20 +0000492
493 logging.info(
494 'OAuth access_token refreshed. Expires in %s.',
495 credentials.token_expiry - datetime.datetime.utcnow())
vadimsh@chromium.org24daf9e2015-04-17 02:42:44 +0000496 storage = self._get_storage()
vadimsh@chromium.orgeed4df32015-04-10 21:30:20 +0000497 credentials.set_store(storage)
498 storage.put(credentials)
vadimsh@chromium.orgafbb0192015-04-13 23:26:31 +0000499 return AccessToken(str(credentials.access_token), credentials.token_expiry)
vadimsh@chromium.orgeed4df32015-04-10 21:30:20 +0000500
501
502## Private functions.
503
504
vadimsh@chromium.orgeed4df32015-04-10 21:30:20 +0000505def _is_headless():
506 """True if machine doesn't seem to have a display."""
507 return sys.platform == 'linux2' and not os.environ.get('DISPLAY')
508
509
vadimsh@chromium.org24daf9e2015-04-17 02:42:44 +0000510def _read_refresh_token_json(path):
511 """Returns RefreshToken by reading it from the JSON file."""
512 try:
513 with open(path, 'r') as f:
514 data = json.load(f)
515 return RefreshToken(
516 client_id=str(data.get('client_id', OAUTH_CLIENT_ID)),
517 client_secret=str(data.get('client_secret', OAUTH_CLIENT_SECRET)),
518 refresh_token=str(data['refresh_token']))
519 except (IOError, ValueError) as e:
520 raise AuthenticationError(
521 'Failed to read refresh token from %s: %s' % (path, e))
522 except KeyError as e:
523 raise AuthenticationError(
524 'Failed to read refresh token from %s: missing key %s' % (path, e))
525
526
vadimsh@chromium.orgeed4df32015-04-10 21:30:20 +0000527def _needs_refresh(access_token):
528 """True if AccessToken should be refreshed."""
529 if access_token.expires_at is not None:
530 # Allow 5 min of clock skew between client and backend.
531 now = datetime.datetime.utcnow() + datetime.timedelta(seconds=300)
532 return now >= access_token.expires_at
533 # Token without expiration time never expires.
534 return False
535
536
vadimsh@chromium.orgcfbeecb2015-04-21 00:12:36 +0000537def _log_credentials_info(title, credentials):
538 """Dumps (non sensitive) part of client.Credentials object to debug log."""
539 if credentials:
540 logging.debug('%s info: %r', title, {
541 'access_token_expired': credentials.access_token_expired,
542 'has_access_token': bool(credentials.access_token),
543 'invalid': credentials.invalid,
544 'utcnow': datetime.datetime.utcnow(),
545 'token_expiry': credentials.token_expiry,
546 })
547
548
vadimsh@chromium.orgeed4df32015-04-10 21:30:20 +0000549def _run_oauth_dance(config):
550 """Perform full 3-legged OAuth2 flow with the browser.
551
552 Returns:
553 oauth2client.Credentials.
554
555 Raises:
556 AuthenticationError on errors.
557 """
558 flow = client.OAuth2WebServerFlow(
559 OAUTH_CLIENT_ID,
560 OAUTH_CLIENT_SECRET,
561 OAUTH_SCOPES,
562 approval_prompt='force')
563
564 use_local_webserver = config.use_local_webserver
565 port = config.webserver_port
566 if config.use_local_webserver:
567 success = False
568 try:
569 httpd = _ClientRedirectServer(('localhost', port), _ClientRedirectHandler)
570 except socket.error:
571 pass
572 else:
573 success = True
574 use_local_webserver = success
575 if not success:
576 print(
577 'Failed to start a local webserver listening on port %d.\n'
578 'Please check your firewall settings and locally running programs that '
579 'may be blocking or using those ports.\n\n'
580 'Falling back to --auth-no-local-webserver and continuing with '
581 'authentication.\n' % port)
582
583 if use_local_webserver:
584 oauth_callback = 'http://localhost:%s/' % port
585 else:
586 oauth_callback = client.OOB_CALLBACK_URN
587 flow.redirect_uri = oauth_callback
588 authorize_url = flow.step1_get_authorize_url()
589
590 if use_local_webserver:
591 webbrowser.open(authorize_url, new=1, autoraise=True)
592 print(
593 'Your browser has been opened to visit:\n\n'
594 ' %s\n\n'
595 'If your browser is on a different machine then exit and re-run this '
596 'application with the command-line parameter\n\n'
597 ' --auth-no-local-webserver\n' % authorize_url)
598 else:
599 print(
600 'Go to the following link in your browser:\n\n'
601 ' %s\n' % authorize_url)
602
603 try:
604 code = None
605 if use_local_webserver:
606 httpd.handle_request()
607 if 'error' in httpd.query_params:
608 raise AuthenticationError(
609 'Authentication request was rejected: %s' %
610 httpd.query_params['error'])
611 if 'code' not in httpd.query_params:
612 raise AuthenticationError(
613 'Failed to find "code" in the query parameters of the redirect.\n'
614 'Try running with --auth-no-local-webserver.')
615 code = httpd.query_params['code']
616 else:
617 code = raw_input('Enter verification code: ').strip()
618 except KeyboardInterrupt:
619 raise AuthenticationError('Authentication was canceled.')
620
621 try:
622 return flow.step2_exchange(code)
623 except client.FlowExchangeError as e:
624 raise AuthenticationError('Authentication has failed: %s' % e)
625
626
627class _ClientRedirectServer(BaseHTTPServer.HTTPServer):
628 """A server to handle OAuth 2.0 redirects back to localhost.
629
630 Waits for a single request and parses the query parameters
631 into query_params and then stops serving.
632 """
633 query_params = {}
634
635
636class _ClientRedirectHandler(BaseHTTPServer.BaseHTTPRequestHandler):
637 """A handler for OAuth 2.0 redirects back to localhost.
638
639 Waits for a single request and parses the query parameters
640 into the servers query_params and then stops serving.
641 """
642
643 def do_GET(self):
644 """Handle a GET request.
645
646 Parses the query parameters and prints a message
647 if the flow has completed. Note that we can't detect
648 if an error occurred.
649 """
650 self.send_response(200)
651 self.send_header('Content-type', 'text/html')
652 self.end_headers()
653 query = self.path.split('?', 1)[-1]
654 query = dict(urlparse.parse_qsl(query))
655 self.server.query_params = query
656 self.wfile.write('<html><head><title>Authentication Status</title></head>')
657 self.wfile.write('<body><p>The authentication flow has completed.</p>')
658 self.wfile.write('</body></html>')
659
660 def log_message(self, _format, *args):
661 """Do not log messages to stdout while running as command line program."""