blob: cc1b82b697f125abe02e7e1c4d1a288ea30e46ea [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.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
vadimsh@chromium.orgeed4df32015-04-10 21:30:20 +0000484def _is_headless():
485 """True if machine doesn't seem to have a display."""
486 return sys.platform == 'linux2' and not os.environ.get('DISPLAY')
487
488
vadimsh@chromium.org24daf9e2015-04-17 02:42:44 +0000489def _read_refresh_token_json(path):
490 """Returns RefreshToken by reading it from the JSON file."""
491 try:
492 with open(path, 'r') as f:
493 data = json.load(f)
494 return RefreshToken(
495 client_id=str(data.get('client_id', OAUTH_CLIENT_ID)),
496 client_secret=str(data.get('client_secret', OAUTH_CLIENT_SECRET)),
497 refresh_token=str(data['refresh_token']))
498 except (IOError, ValueError) as e:
499 raise AuthenticationError(
500 'Failed to read refresh token from %s: %s' % (path, e))
501 except KeyError as e:
502 raise AuthenticationError(
503 'Failed to read refresh token from %s: missing key %s' % (path, e))
504
505
vadimsh@chromium.orgeed4df32015-04-10 21:30:20 +0000506def _needs_refresh(access_token):
507 """True if AccessToken should be refreshed."""
508 if access_token.expires_at is not None:
509 # Allow 5 min of clock skew between client and backend.
510 now = datetime.datetime.utcnow() + datetime.timedelta(seconds=300)
511 return now >= access_token.expires_at
512 # Token without expiration time never expires.
513 return False
514
515
516def _run_oauth_dance(config):
517 """Perform full 3-legged OAuth2 flow with the browser.
518
519 Returns:
520 oauth2client.Credentials.
521
522 Raises:
523 AuthenticationError on errors.
524 """
525 flow = client.OAuth2WebServerFlow(
526 OAUTH_CLIENT_ID,
527 OAUTH_CLIENT_SECRET,
528 OAUTH_SCOPES,
529 approval_prompt='force')
530
531 use_local_webserver = config.use_local_webserver
532 port = config.webserver_port
533 if config.use_local_webserver:
534 success = False
535 try:
536 httpd = _ClientRedirectServer(('localhost', port), _ClientRedirectHandler)
537 except socket.error:
538 pass
539 else:
540 success = True
541 use_local_webserver = success
542 if not success:
543 print(
544 'Failed to start a local webserver listening on port %d.\n'
545 'Please check your firewall settings and locally running programs that '
546 'may be blocking or using those ports.\n\n'
547 'Falling back to --auth-no-local-webserver and continuing with '
548 'authentication.\n' % port)
549
550 if use_local_webserver:
551 oauth_callback = 'http://localhost:%s/' % port
552 else:
553 oauth_callback = client.OOB_CALLBACK_URN
554 flow.redirect_uri = oauth_callback
555 authorize_url = flow.step1_get_authorize_url()
556
557 if use_local_webserver:
558 webbrowser.open(authorize_url, new=1, autoraise=True)
559 print(
560 'Your browser has been opened to visit:\n\n'
561 ' %s\n\n'
562 'If your browser is on a different machine then exit and re-run this '
563 'application with the command-line parameter\n\n'
564 ' --auth-no-local-webserver\n' % authorize_url)
565 else:
566 print(
567 'Go to the following link in your browser:\n\n'
568 ' %s\n' % authorize_url)
569
570 try:
571 code = None
572 if use_local_webserver:
573 httpd.handle_request()
574 if 'error' in httpd.query_params:
575 raise AuthenticationError(
576 'Authentication request was rejected: %s' %
577 httpd.query_params['error'])
578 if 'code' not in httpd.query_params:
579 raise AuthenticationError(
580 'Failed to find "code" in the query parameters of the redirect.\n'
581 'Try running with --auth-no-local-webserver.')
582 code = httpd.query_params['code']
583 else:
584 code = raw_input('Enter verification code: ').strip()
585 except KeyboardInterrupt:
586 raise AuthenticationError('Authentication was canceled.')
587
588 try:
589 return flow.step2_exchange(code)
590 except client.FlowExchangeError as e:
591 raise AuthenticationError('Authentication has failed: %s' % e)
592
593
594class _ClientRedirectServer(BaseHTTPServer.HTTPServer):
595 """A server to handle OAuth 2.0 redirects back to localhost.
596
597 Waits for a single request and parses the query parameters
598 into query_params and then stops serving.
599 """
600 query_params = {}
601
602
603class _ClientRedirectHandler(BaseHTTPServer.BaseHTTPRequestHandler):
604 """A handler for OAuth 2.0 redirects back to localhost.
605
606 Waits for a single request and parses the query parameters
607 into the servers query_params and then stops serving.
608 """
609
610 def do_GET(self):
611 """Handle a GET request.
612
613 Parses the query parameters and prints a message
614 if the flow has completed. Note that we can't detect
615 if an error occurred.
616 """
617 self.send_response(200)
618 self.send_header('Content-type', 'text/html')
619 self.end_headers()
620 query = self.path.split('?', 1)[-1]
621 query = dict(urlparse.parse_qsl(query))
622 self.server.query_params = query
623 self.wfile.write('<html><head><title>Authentication Status</title></head>')
624 self.wfile.write('<body><p>The authentication flow has completed.</p>')
625 self.wfile.write('</body></html>')
626
627 def log_message(self, _format, *args):
628 """Do not log messages to stdout while running as command line program."""