blob: 15fc15deb9033d08bf0370216902912fbe9ebad1 [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.orgeed4df32015-04-10 21:30:20 +0000114 default(use_oauth2, _should_use_oauth2()),
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.orgeed4df32015-04-10 21:30:20 +0000248
249 def login(self):
250 """Performs interactive login flow if necessary.
251
252 Raises:
253 AuthenticationError on error or if interrupted.
254 """
vadimsh@chromium.org24daf9e2015-04-17 02:42:44 +0000255 if self._external_token:
256 raise AuthenticationError(
257 'Can\'t run login flow when using --auth-refresh-token-json.')
vadimsh@chromium.orgeed4df32015-04-10 21:30:20 +0000258 return self.get_access_token(
259 force_refresh=True, allow_user_interaction=True)
260
261 def logout(self):
262 """Revokes the refresh token and deletes it from the cache.
263
vadimsh@chromium.org24daf9e2015-04-17 02:42:44 +0000264 Returns True if had some credentials cached.
vadimsh@chromium.orgeed4df32015-04-10 21:30:20 +0000265 """
vadimsh@chromium.orgeed4df32015-04-10 21:30:20 +0000266 with self._lock:
267 self._access_token = None
268 storage = self._get_storage()
269 credentials = storage.get()
vadimsh@chromium.org24daf9e2015-04-17 02:42:44 +0000270 had_creds = bool(credentials)
271 if credentials and credentials.refresh_token and credentials.revoke_uri:
272 try:
273 credentials.revoke(httplib2.Http())
274 except client.TokenRevokeError as e:
275 logging.warning('Failed to revoke refresh token: %s', e)
vadimsh@chromium.orgeed4df32015-04-10 21:30:20 +0000276 storage.delete()
vadimsh@chromium.org24daf9e2015-04-17 02:42:44 +0000277 return had_creds
vadimsh@chromium.orgeed4df32015-04-10 21:30:20 +0000278
279 def has_cached_credentials(self):
280 """Returns True if long term credentials (refresh token) are in cache.
281
282 Doesn't make network calls.
283
284 If returns False, get_access_token() later will ask for interactive login by
285 raising LoginRequiredError.
286
287 If returns True, most probably get_access_token() won't ask for interactive
288 login, though it is not guaranteed, since cached token can be already
289 revoked and there's no way to figure this out without actually trying to use
290 it.
291 """
292 with self._lock:
vadimsh@chromium.org24daf9e2015-04-17 02:42:44 +0000293 return bool(self._get_cached_credentials())
vadimsh@chromium.orgeed4df32015-04-10 21:30:20 +0000294
295 def get_access_token(self, force_refresh=False, allow_user_interaction=False):
296 """Returns AccessToken, refreshing it if necessary.
297
298 Args:
299 force_refresh: forcefully refresh access token even if it is not expired.
300 allow_user_interaction: True to enable blocking for user input if needed.
301
302 Raises:
303 AuthenticationError on error or if authentication flow was interrupted.
304 LoginRequiredError if user interaction is required, but
305 allow_user_interaction is False.
306 """
307 with self._lock:
308 if force_refresh:
309 self._access_token = self._create_access_token(allow_user_interaction)
310 return self._access_token
311
312 # Load from on-disk cache on a first access.
313 if not self._access_token:
314 self._access_token = self._load_access_token()
315
316 # Refresh if expired or missing.
317 if not self._access_token or _needs_refresh(self._access_token):
318 # Maybe some other process already updated it, reload from the cache.
319 self._access_token = self._load_access_token()
320 # Nope, still expired, need to run the refresh flow.
321 if not self._access_token or _needs_refresh(self._access_token):
322 self._access_token = self._create_access_token(allow_user_interaction)
323
324 return self._access_token
325
326 def get_token_info(self):
327 """Returns a result of /oauth2/v2/tokeninfo call with token info."""
328 access_token = self.get_access_token()
329 resp, content = httplib2.Http().request(
330 uri='https://www.googleapis.com/oauth2/v2/tokeninfo?%s' % (
331 urllib.urlencode({'access_token': access_token.token})))
332 if resp.status == 200:
333 return json.loads(content)
334 raise AuthenticationError('Failed to fetch the token info: %r' % content)
335
336 def authorize(self, http):
337 """Monkey patches authentication logic of httplib2.Http instance.
338
339 The modified http.request method will add authentication headers to each
340 request and will refresh access_tokens when a 401 is received on a
341 request.
342
343 Args:
344 http: An instance of httplib2.Http.
345
346 Returns:
347 A modified instance of http that was passed in.
348 """
349 # Adapted from oauth2client.OAuth2Credentials.authorize.
350
351 request_orig = http.request
352
353 @functools.wraps(request_orig)
354 def new_request(
355 uri, method='GET', body=None, headers=None,
356 redirections=httplib2.DEFAULT_MAX_REDIRECTS,
357 connection_type=None):
358 headers = (headers or {}).copy()
vadimsh@chromium.orgafbb0192015-04-13 23:26:31 +0000359 headers['Authorization'] = 'Bearer %s' % self.get_access_token().token
vadimsh@chromium.orgeed4df32015-04-10 21:30:20 +0000360 resp, content = request_orig(
361 uri, method, body, headers, redirections, connection_type)
362 if resp.status in client.REFRESH_STATUS_CODES:
363 logging.info('Refreshing due to a %s', resp.status)
364 access_token = self.get_access_token(force_refresh=True)
vadimsh@chromium.orgafbb0192015-04-13 23:26:31 +0000365 headers['Authorization'] = 'Bearer %s' % access_token.token
vadimsh@chromium.orgeed4df32015-04-10 21:30:20 +0000366 return request_orig(
367 uri, method, body, headers, redirections, connection_type)
368 else:
369 return (resp, content)
370
371 http.request = new_request
372 return http
373
374 ## Private methods.
375
376 def _get_storage(self):
377 """Returns oauth2client.Storage with cached tokens."""
vadimsh@chromium.org24daf9e2015-04-17 02:42:44 +0000378 # Do not mix cache keys for different externally provided tokens.
379 if self._external_token:
380 token_hash = hashlib.sha1(self._external_token.refresh_token).hexdigest()
381 cache_key = '%s:refresh_tok:%s' % (self._token_cache_key, token_hash)
382 else:
383 cache_key = self._token_cache_key
vadimsh@chromium.orgeed4df32015-04-10 21:30:20 +0000384 return multistore_file.get_credential_storage_custom_string_key(
vadimsh@chromium.org24daf9e2015-04-17 02:42:44 +0000385 OAUTH_TOKENS_CACHE, cache_key)
386
387 def _get_cached_credentials(self):
388 """Returns oauth2client.Credentials loaded from storage."""
389 storage = self._get_storage()
390 credentials = storage.get()
391
392 # Is using --auth-refresh-token-json?
393 if self._external_token:
394 # Cached credentials are valid and match external token -> use them. It is
395 # important to reuse credentials from the storage because they contain
396 # cached access token.
397 valid = (
398 credentials and not credentials.invalid and
399 credentials.refresh_token == self._external_token.refresh_token and
400 credentials.client_id == self._external_token.client_id and
401 credentials.client_secret == self._external_token.client_secret)
402 if valid:
403 return credentials
404 # Construct new credentials from externally provided refresh token,
405 # associate them with cache storage (so that access_token will be placed
406 # in the cache later too).
407 credentials = client.OAuth2Credentials(
408 access_token=None,
409 client_id=self._external_token.client_id,
410 client_secret=self._external_token.client_secret,
411 refresh_token=self._external_token.refresh_token,
412 token_expiry=None,
413 token_uri='https://accounts.google.com/o/oauth2/token',
414 user_agent=None,
415 revoke_uri=None)
416 credentials.set_store(storage)
417 storage.put(credentials)
418 return credentials
419
420 # Not using external refresh token -> return whatever is cached.
421 return credentials if (credentials and not credentials.invalid) else None
vadimsh@chromium.orgeed4df32015-04-10 21:30:20 +0000422
423 def _load_access_token(self):
424 """Returns cached AccessToken if it is not expired yet."""
vadimsh@chromium.org24daf9e2015-04-17 02:42:44 +0000425 creds = self._get_cached_credentials()
426 if not creds or not creds.access_token or creds.access_token_expired:
vadimsh@chromium.orgeed4df32015-04-10 21:30:20 +0000427 return None
vadimsh@chromium.org24daf9e2015-04-17 02:42:44 +0000428 return AccessToken(str(creds.access_token), creds.token_expiry)
vadimsh@chromium.orgeed4df32015-04-10 21:30:20 +0000429
430 def _create_access_token(self, allow_user_interaction=False):
431 """Mints and caches a new access token, launching OAuth2 dance if necessary.
432
433 Uses cached refresh token, if present. In that case user interaction is not
434 required and function will finish quietly. Otherwise it will launch 3-legged
435 OAuth2 flow, that needs user interaction.
436
437 Args:
438 allow_user_interaction: if True, allow interaction with the user (e.g.
439 reading standard input, or launching a browser).
440
441 Returns:
442 AccessToken.
443
444 Raises:
445 AuthenticationError on error or if authentication flow was interrupted.
446 LoginRequiredError if user interaction is required, but
447 allow_user_interaction is False.
448 """
vadimsh@chromium.org24daf9e2015-04-17 02:42:44 +0000449 credentials = self._get_cached_credentials()
vadimsh@chromium.orgeed4df32015-04-10 21:30:20 +0000450
451 # 3-legged flow with (perhaps cached) refresh token.
vadimsh@chromium.orgeed4df32015-04-10 21:30:20 +0000452 refreshed = False
453 if credentials and not credentials.invalid:
454 try:
455 credentials.refresh(httplib2.Http())
456 refreshed = True
457 except client.Error as err:
458 logging.warning(
vadimsh@chromium.org24daf9e2015-04-17 02:42:44 +0000459 'OAuth error during access token refresh (%s). '
vadimsh@chromium.orgeed4df32015-04-10 21:30:20 +0000460 'Attempting a full authentication flow.', err)
461
462 # Refresh token is missing or invalid, go through the full flow.
463 if not refreshed:
vadimsh@chromium.org24daf9e2015-04-17 02:42:44 +0000464 # Can't refresh externally provided token.
465 if self._external_token:
466 raise AuthenticationError(
467 'Token provided via --auth-refresh-token-json is no longer valid.')
vadimsh@chromium.orgeed4df32015-04-10 21:30:20 +0000468 if not allow_user_interaction:
469 raise LoginRequiredError(self._token_cache_key)
470 credentials = _run_oauth_dance(self._config)
471
472 logging.info(
473 'OAuth access_token refreshed. Expires in %s.',
474 credentials.token_expiry - datetime.datetime.utcnow())
vadimsh@chromium.org24daf9e2015-04-17 02:42:44 +0000475 storage = self._get_storage()
vadimsh@chromium.orgeed4df32015-04-10 21:30:20 +0000476 credentials.set_store(storage)
477 storage.put(credentials)
vadimsh@chromium.orgafbb0192015-04-13 23:26:31 +0000478 return AccessToken(str(credentials.access_token), credentials.token_expiry)
vadimsh@chromium.orgeed4df32015-04-10 21:30:20 +0000479
480
481## Private functions.
482
483
484def _should_use_oauth2():
485 """Default value for use_oauth2 config option.
486
487 Used to selectively enable OAuth2 by default.
488 """
489 return os.path.exists(os.path.join(DEPOT_TOOLS_DIR, 'USE_OAUTH2'))
490
491
492def _is_headless():
493 """True if machine doesn't seem to have a display."""
494 return sys.platform == 'linux2' and not os.environ.get('DISPLAY')
495
496
vadimsh@chromium.org24daf9e2015-04-17 02:42:44 +0000497def _read_refresh_token_json(path):
498 """Returns RefreshToken by reading it from the JSON file."""
499 try:
500 with open(path, 'r') as f:
501 data = json.load(f)
502 return RefreshToken(
503 client_id=str(data.get('client_id', OAUTH_CLIENT_ID)),
504 client_secret=str(data.get('client_secret', OAUTH_CLIENT_SECRET)),
505 refresh_token=str(data['refresh_token']))
506 except (IOError, ValueError) as e:
507 raise AuthenticationError(
508 'Failed to read refresh token from %s: %s' % (path, e))
509 except KeyError as e:
510 raise AuthenticationError(
511 'Failed to read refresh token from %s: missing key %s' % (path, e))
512
513
vadimsh@chromium.orgeed4df32015-04-10 21:30:20 +0000514def _needs_refresh(access_token):
515 """True if AccessToken should be refreshed."""
516 if access_token.expires_at is not None:
517 # Allow 5 min of clock skew between client and backend.
518 now = datetime.datetime.utcnow() + datetime.timedelta(seconds=300)
519 return now >= access_token.expires_at
520 # Token without expiration time never expires.
521 return False
522
523
524def _run_oauth_dance(config):
525 """Perform full 3-legged OAuth2 flow with the browser.
526
527 Returns:
528 oauth2client.Credentials.
529
530 Raises:
531 AuthenticationError on errors.
532 """
533 flow = client.OAuth2WebServerFlow(
534 OAUTH_CLIENT_ID,
535 OAUTH_CLIENT_SECRET,
536 OAUTH_SCOPES,
537 approval_prompt='force')
538
539 use_local_webserver = config.use_local_webserver
540 port = config.webserver_port
541 if config.use_local_webserver:
542 success = False
543 try:
544 httpd = _ClientRedirectServer(('localhost', port), _ClientRedirectHandler)
545 except socket.error:
546 pass
547 else:
548 success = True
549 use_local_webserver = success
550 if not success:
551 print(
552 'Failed to start a local webserver listening on port %d.\n'
553 'Please check your firewall settings and locally running programs that '
554 'may be blocking or using those ports.\n\n'
555 'Falling back to --auth-no-local-webserver and continuing with '
556 'authentication.\n' % port)
557
558 if use_local_webserver:
559 oauth_callback = 'http://localhost:%s/' % port
560 else:
561 oauth_callback = client.OOB_CALLBACK_URN
562 flow.redirect_uri = oauth_callback
563 authorize_url = flow.step1_get_authorize_url()
564
565 if use_local_webserver:
566 webbrowser.open(authorize_url, new=1, autoraise=True)
567 print(
568 'Your browser has been opened to visit:\n\n'
569 ' %s\n\n'
570 'If your browser is on a different machine then exit and re-run this '
571 'application with the command-line parameter\n\n'
572 ' --auth-no-local-webserver\n' % authorize_url)
573 else:
574 print(
575 'Go to the following link in your browser:\n\n'
576 ' %s\n' % authorize_url)
577
578 try:
579 code = None
580 if use_local_webserver:
581 httpd.handle_request()
582 if 'error' in httpd.query_params:
583 raise AuthenticationError(
584 'Authentication request was rejected: %s' %
585 httpd.query_params['error'])
586 if 'code' not in httpd.query_params:
587 raise AuthenticationError(
588 'Failed to find "code" in the query parameters of the redirect.\n'
589 'Try running with --auth-no-local-webserver.')
590 code = httpd.query_params['code']
591 else:
592 code = raw_input('Enter verification code: ').strip()
593 except KeyboardInterrupt:
594 raise AuthenticationError('Authentication was canceled.')
595
596 try:
597 return flow.step2_exchange(code)
598 except client.FlowExchangeError as e:
599 raise AuthenticationError('Authentication has failed: %s' % e)
600
601
602class _ClientRedirectServer(BaseHTTPServer.HTTPServer):
603 """A server to handle OAuth 2.0 redirects back to localhost.
604
605 Waits for a single request and parses the query parameters
606 into query_params and then stops serving.
607 """
608 query_params = {}
609
610
611class _ClientRedirectHandler(BaseHTTPServer.BaseHTTPRequestHandler):
612 """A handler for OAuth 2.0 redirects back to localhost.
613
614 Waits for a single request and parses the query parameters
615 into the servers query_params and then stops serving.
616 """
617
618 def do_GET(self):
619 """Handle a GET request.
620
621 Parses the query parameters and prints a message
622 if the flow has completed. Note that we can't detect
623 if an error occurred.
624 """
625 self.send_response(200)
626 self.send_header('Content-type', 'text/html')
627 self.end_headers()
628 query = self.path.split('?', 1)[-1]
629 query = dict(urlparse.parse_qsl(query))
630 self.server.query_params = query
631 self.wfile.write('<html><head><title>Authentication Status</title></head>')
632 self.wfile.write('<body><p>The authentication flow has completed.</p>')
633 self.wfile.write('</body></html>')
634
635 def log_message(self, _format, *args):
636 """Do not log messages to stdout while running as command line program."""