Chromium Code Reviews
chromiumcodereview-hr@appspot.gserviceaccount.com (chromiumcodereview-hr) | Please choose your nickname with Settings | Help | Chromium Project | Gerrit Changes | Sign out
(40)

Side by Side Diff: auth.py

Issue 1060193005: Add support for externally provided refresh tokens. (Closed) Base URL: svn://svn.chromium.org/chrome/trunk/tools/depot_tools
Patch Set: Created 5 years, 8 months ago
Use n/p to move between diff chunks; N/P to move between comments. Draft comments are only viewable by you.
Jump to:
View unified diff | Download patch | Annotate | Revision Log
« no previous file with comments | « no previous file | tests/git_cl_test.py » ('j') | no next file with comments »
Toggle Intra-line Diffs ('i') | Expand Comments ('e') | Collapse Comments ('c') | Show Comments Hide Comments ('s')
OLDNEW
1 # Copyright 2015 The Chromium Authors. All rights reserved. 1 # Copyright 2015 The Chromium Authors. All rights reserved.
2 # Use of this source code is governed by a BSD-style license that can be 2 # Use of this source code is governed by a BSD-style license that can be
3 # found in the LICENSE file. 3 # found in the LICENSE file.
4 4
5 """Google OAuth2 related functions.""" 5 """Google OAuth2 related functions."""
6 6
7 import BaseHTTPServer 7 import BaseHTTPServer
8 import collections 8 import collections
9 import datetime 9 import datetime
10 import functools 10 import functools
11 import hashlib
11 import json 12 import json
12 import logging 13 import logging
13 import optparse 14 import optparse
14 import os 15 import os
15 import socket 16 import socket
16 import sys 17 import sys
17 import threading 18 import threading
18 import urllib 19 import urllib
19 import urlparse 20 import urlparse
20 import webbrowser 21 import webbrowser
(...skipping 34 matching lines...) Expand 10 before | Expand all | Expand 10 after
55 os.path.expanduser('~'), '.depot_tools_oauth2_tokens') 56 os.path.expanduser('~'), '.depot_tools_oauth2_tokens')
56 57
57 58
58 # Authentication configuration extracted from command line options. 59 # Authentication configuration extracted from command line options.
59 # See doc string for 'make_auth_config' for meaning of fields. 60 # See doc string for 'make_auth_config' for meaning of fields.
60 AuthConfig = collections.namedtuple('AuthConfig', [ 61 AuthConfig = collections.namedtuple('AuthConfig', [
61 'use_oauth2', # deprecated, will be always True 62 'use_oauth2', # deprecated, will be always True
62 'save_cookies', # deprecated, will be removed 63 'save_cookies', # deprecated, will be removed
63 'use_local_webserver', 64 'use_local_webserver',
64 'webserver_port', 65 'webserver_port',
66 'refresh_token_json',
65 ]) 67 ])
66 68
67 69
68 # OAuth access token with its expiration time (UTC datetime or None if unknown). 70 # OAuth access token with its expiration time (UTC datetime or None if unknown).
69 AccessToken = collections.namedtuple('AccessToken', [ 71 AccessToken = collections.namedtuple('AccessToken', [
70 'token', 72 'token',
71 'expires_at', 73 'expires_at',
72 ]) 74 ])
73 75
74 76
77 # Refresh token passed via --auth-refresh-token-json.
78 RefreshToken = collections.namedtuple('RefreshToken', [
79 'client_id',
80 'client_secret',
81 'refresh_token',
82 ])
83
84
75 class AuthenticationError(Exception): 85 class AuthenticationError(Exception):
76 """Raised on errors related to authentication.""" 86 """Raised on errors related to authentication."""
77 87
78 88
79 class LoginRequiredError(AuthenticationError): 89 class LoginRequiredError(AuthenticationError):
80 """Interaction with the user is required to authenticate.""" 90 """Interaction with the user is required to authenticate."""
81 91
82 def __init__(self, token_cache_key): 92 def __init__(self, token_cache_key):
83 # HACK(vadimsh): It is assumed here that the token cache key is a hostname. 93 # HACK(vadimsh): It is assumed here that the token cache key is a hostname.
84 msg = ( 94 msg = (
85 'You are not logged in. Please login first by running:\n' 95 'You are not logged in. Please login first by running:\n'
86 ' depot-tools-auth login %s' % token_cache_key) 96 ' depot-tools-auth login %s' % token_cache_key)
87 super(LoginRequiredError, self).__init__(msg) 97 super(LoginRequiredError, self).__init__(msg)
88 98
89 99
90 def make_auth_config( 100 def make_auth_config(
91 use_oauth2=None, 101 use_oauth2=None,
92 save_cookies=None, 102 save_cookies=None,
93 use_local_webserver=None, 103 use_local_webserver=None,
94 webserver_port=None): 104 webserver_port=None,
105 refresh_token_json=None):
95 """Returns new instance of AuthConfig. 106 """Returns new instance of AuthConfig.
96 107
97 If some config option is None, it will be set to a reasonable default value. 108 If some config option is None, it will be set to a reasonable default value.
98 This function also acts as an authoritative place for default values of 109 This function also acts as an authoritative place for default values of
99 corresponding command line options. 110 corresponding command line options.
100 """ 111 """
101 default = lambda val, d: val if val is not None else d 112 default = lambda val, d: val if val is not None else d
102 return AuthConfig( 113 return AuthConfig(
103 default(use_oauth2, _should_use_oauth2()), 114 default(use_oauth2, _should_use_oauth2()),
104 default(save_cookies, True), 115 default(save_cookies, True),
105 default(use_local_webserver, not _is_headless()), 116 default(use_local_webserver, not _is_headless()),
106 default(webserver_port, 8090)) 117 default(webserver_port, 8090),
118 default(refresh_token_json, ''))
107 119
108 120
109 def add_auth_options(parser, default_config=None): 121 def add_auth_options(parser, default_config=None):
110 """Appends OAuth related options to OptionParser.""" 122 """Appends OAuth related options to OptionParser."""
111 default_config = default_config or make_auth_config() 123 default_config = default_config or make_auth_config()
112 parser.auth_group = optparse.OptionGroup(parser, 'Auth options') 124 parser.auth_group = optparse.OptionGroup(parser, 'Auth options')
113 parser.add_option_group(parser.auth_group) 125 parser.add_option_group(parser.auth_group)
114 126
115 # OAuth2 vs password switch. 127 # OAuth2 vs password switch.
116 auth_default = 'use OAuth2' if default_config.use_oauth2 else 'use password' 128 auth_default = 'use OAuth2' if default_config.use_oauth2 else 'use password'
(...skipping 24 matching lines...) Expand all
141 action='store_false', 153 action='store_false',
142 dest='use_local_webserver', 154 dest='use_local_webserver',
143 default=default_config.use_local_webserver, 155 default=default_config.use_local_webserver,
144 help='Do not run a local web server when performing OAuth2 login flow.') 156 help='Do not run a local web server when performing OAuth2 login flow.')
145 parser.auth_group.add_option( 157 parser.auth_group.add_option(
146 '--auth-host-port', 158 '--auth-host-port',
147 type=int, 159 type=int,
148 default=default_config.webserver_port, 160 default=default_config.webserver_port,
149 help='Port a local web server should listen on. Used only if ' 161 help='Port a local web server should listen on. Used only if '
150 '--auth-no-local-webserver is not set. [default: %default]') 162 '--auth-no-local-webserver is not set. [default: %default]')
163 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.')
151 167
152 168
153 def extract_auth_config_from_options(options): 169 def extract_auth_config_from_options(options):
154 """Given OptionParser parsed options, extracts AuthConfig from it. 170 """Given OptionParser parsed options, extracts AuthConfig from it.
155 171
156 OptionParser should be populated with auth options by 'add_auth_options'. 172 OptionParser should be populated with auth options by 'add_auth_options'.
157 """ 173 """
158 return make_auth_config( 174 return make_auth_config(
159 use_oauth2=options.use_oauth2, 175 use_oauth2=options.use_oauth2,
160 save_cookies=False if options.use_oauth2 else options.save_cookies, 176 save_cookies=False if options.use_oauth2 else options.save_cookies,
161 use_local_webserver=options.use_local_webserver, 177 use_local_webserver=options.use_local_webserver,
162 webserver_port=options.auth_host_port) 178 webserver_port=options.auth_host_port,
179 refresh_token_json=options.auth_refresh_token_json)
163 180
164 181
165 def auth_config_to_command_options(auth_config): 182 def auth_config_to_command_options(auth_config):
166 """AuthConfig -> list of strings with command line options. 183 """AuthConfig -> list of strings with command line options.
167 184
168 Omits options that are set to default values. 185 Omits options that are set to default values.
169 """ 186 """
170 if not auth_config: 187 if not auth_config:
171 return [] 188 return []
172 defaults = make_auth_config() 189 defaults = make_auth_config()
173 opts = [] 190 opts = []
174 if auth_config.use_oauth2 != defaults.use_oauth2: 191 if auth_config.use_oauth2 != defaults.use_oauth2:
175 opts.append('--oauth2' if auth_config.use_oauth2 else '--no-oauth2') 192 opts.append('--oauth2' if auth_config.use_oauth2 else '--no-oauth2')
176 if auth_config.save_cookies != auth_config.save_cookies: 193 if auth_config.save_cookies != auth_config.save_cookies:
177 if not auth_config.save_cookies: 194 if not auth_config.save_cookies:
178 opts.append('--no-cookies') 195 opts.append('--no-cookies')
179 if auth_config.use_local_webserver != defaults.use_local_webserver: 196 if auth_config.use_local_webserver != defaults.use_local_webserver:
180 if not auth_config.use_local_webserver: 197 if not auth_config.use_local_webserver:
181 opts.append('--auth-no-local-webserver') 198 opts.append('--auth-no-local-webserver')
182 if auth_config.webserver_port != defaults.webserver_port: 199 if auth_config.webserver_port != defaults.webserver_port:
183 opts.extend(['--auth-host-port', str(auth_config.webserver_port)]) 200 opts.extend(['--auth-host-port', str(auth_config.webserver_port)])
201 if auth_config.refresh_token_json != defaults.refresh_token_json:
202 opts.extend([
203 '--auth-refresh-token-json', str(auth_config.refresh_token_json)])
184 return opts 204 return opts
185 205
186 206
187 def get_authenticator_for_host(hostname, config): 207 def get_authenticator_for_host(hostname, config):
188 """Returns Authenticator instance to access given host. 208 """Returns Authenticator instance to access given host.
189 209
190 Args: 210 Args:
191 hostname: a naked hostname or http(s)://<hostname>[/] URL. Used to derive 211 hostname: a naked hostname or http(s)://<hostname>[/] URL. Used to derive
192 a cache key for token cache. 212 a cache key for token cache.
193 config: AuthConfig instance. 213 config: AuthConfig instance.
(...skipping 21 matching lines...) Expand all
215 config: AuthConfig object that holds authentication configuration. 235 config: AuthConfig object that holds authentication configuration.
216 """ 236 """
217 237
218 def __init__(self, token_cache_key, config): 238 def __init__(self, token_cache_key, config):
219 assert isinstance(config, AuthConfig) 239 assert isinstance(config, AuthConfig)
220 assert config.use_oauth2 240 assert config.use_oauth2
221 self._access_token = None 241 self._access_token = None
222 self._config = config 242 self._config = config
223 self._lock = threading.Lock() 243 self._lock = threading.Lock()
224 self._token_cache_key = token_cache_key 244 self._token_cache_key = token_cache_key
245 self._external_token = None
246 if config.refresh_token_json:
247 self._external_token = _read_refresh_token_json(config.refresh_token_json)
225 248
226 def login(self): 249 def login(self):
227 """Performs interactive login flow if necessary. 250 """Performs interactive login flow if necessary.
228 251
229 Raises: 252 Raises:
230 AuthenticationError on error or if interrupted. 253 AuthenticationError on error or if interrupted.
231 """ 254 """
255 if self._external_token:
256 raise AuthenticationError(
257 'Can\'t run login flow when using --auth-refresh-token-json.')
232 return self.get_access_token( 258 return self.get_access_token(
233 force_refresh=True, allow_user_interaction=True) 259 force_refresh=True, allow_user_interaction=True)
234 260
235 def logout(self): 261 def logout(self):
236 """Revokes the refresh token and deletes it from the cache. 262 """Revokes the refresh token and deletes it from the cache.
237 263
238 Returns True if actually revoked a token. 264 Returns True if had some credentials cached.
239 """ 265 """
240 revoked = False
241 with self._lock: 266 with self._lock:
242 self._access_token = None 267 self._access_token = None
243 storage = self._get_storage() 268 storage = self._get_storage()
244 credentials = storage.get() 269 credentials = storage.get()
245 if credentials: 270 had_creds = bool(credentials)
246 credentials.revoke(httplib2.Http()) 271 if credentials and credentials.refresh_token and credentials.revoke_uri:
247 revoked = True 272 try:
273 credentials.revoke(httplib2.Http())
274 except client.TokenRevokeError as e:
275 logging.warning('Failed to revoke refresh token: %s', e)
248 storage.delete() 276 storage.delete()
249 return revoked 277 return had_creds
250 278
251 def has_cached_credentials(self): 279 def has_cached_credentials(self):
252 """Returns True if long term credentials (refresh token) are in cache. 280 """Returns True if long term credentials (refresh token) are in cache.
253 281
254 Doesn't make network calls. 282 Doesn't make network calls.
255 283
256 If returns False, get_access_token() later will ask for interactive login by 284 If returns False, get_access_token() later will ask for interactive login by
257 raising LoginRequiredError. 285 raising LoginRequiredError.
258 286
259 If returns True, most probably get_access_token() won't ask for interactive 287 If returns True, most probably get_access_token() won't ask for interactive
260 login, though it is not guaranteed, since cached token can be already 288 login, though it is not guaranteed, since cached token can be already
261 revoked and there's no way to figure this out without actually trying to use 289 revoked and there's no way to figure this out without actually trying to use
262 it. 290 it.
263 """ 291 """
264 with self._lock: 292 with self._lock:
265 credentials = self._get_storage().get() 293 return bool(self._get_cached_credentials())
266 return credentials and not credentials.invalid
267 294
268 def get_access_token(self, force_refresh=False, allow_user_interaction=False): 295 def get_access_token(self, force_refresh=False, allow_user_interaction=False):
269 """Returns AccessToken, refreshing it if necessary. 296 """Returns AccessToken, refreshing it if necessary.
270 297
271 Args: 298 Args:
272 force_refresh: forcefully refresh access token even if it is not expired. 299 force_refresh: forcefully refresh access token even if it is not expired.
273 allow_user_interaction: True to enable blocking for user input if needed. 300 allow_user_interaction: True to enable blocking for user input if needed.
274 301
275 Raises: 302 Raises:
276 AuthenticationError on error or if authentication flow was interrupted. 303 AuthenticationError on error or if authentication flow was interrupted.
(...skipping 64 matching lines...) Expand 10 before | Expand all | Expand 10 after
341 else: 368 else:
342 return (resp, content) 369 return (resp, content)
343 370
344 http.request = new_request 371 http.request = new_request
345 return http 372 return http
346 373
347 ## Private methods. 374 ## Private methods.
348 375
349 def _get_storage(self): 376 def _get_storage(self):
350 """Returns oauth2client.Storage with cached tokens.""" 377 """Returns oauth2client.Storage with cached tokens."""
378 # 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
351 return multistore_file.get_credential_storage_custom_string_key( 384 return multistore_file.get_credential_storage_custom_string_key(
352 OAUTH_TOKENS_CACHE, self._token_cache_key) 385 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
353 422
354 def _load_access_token(self): 423 def _load_access_token(self):
355 """Returns cached AccessToken if it is not expired yet.""" 424 """Returns cached AccessToken if it is not expired yet."""
356 credentials = self._get_storage().get() 425 creds = self._get_cached_credentials()
357 if not credentials or credentials.invalid: 426 if not creds or not creds.access_token or creds.access_token_expired:
358 return None 427 return None
359 if not credentials.access_token or credentials.access_token_expired: 428 return AccessToken(str(creds.access_token), creds.token_expiry)
360 return None
361 return AccessToken(str(credentials.access_token), credentials.token_expiry)
362 429
363 def _create_access_token(self, allow_user_interaction=False): 430 def _create_access_token(self, allow_user_interaction=False):
364 """Mints and caches a new access token, launching OAuth2 dance if necessary. 431 """Mints and caches a new access token, launching OAuth2 dance if necessary.
365 432
366 Uses cached refresh token, if present. In that case user interaction is not 433 Uses cached refresh token, if present. In that case user interaction is not
367 required and function will finish quietly. Otherwise it will launch 3-legged 434 required and function will finish quietly. Otherwise it will launch 3-legged
368 OAuth2 flow, that needs user interaction. 435 OAuth2 flow, that needs user interaction.
369 436
370 Args: 437 Args:
371 allow_user_interaction: if True, allow interaction with the user (e.g. 438 allow_user_interaction: if True, allow interaction with the user (e.g.
372 reading standard input, or launching a browser). 439 reading standard input, or launching a browser).
373 440
374 Returns: 441 Returns:
375 AccessToken. 442 AccessToken.
376 443
377 Raises: 444 Raises:
378 AuthenticationError on error or if authentication flow was interrupted. 445 AuthenticationError on error or if authentication flow was interrupted.
379 LoginRequiredError if user interaction is required, but 446 LoginRequiredError if user interaction is required, but
380 allow_user_interaction is False. 447 allow_user_interaction is False.
381 """ 448 """
382 storage = self._get_storage() 449 credentials = self._get_cached_credentials()
383 credentials = None
384 450
385 # 3-legged flow with (perhaps cached) refresh token. 451 # 3-legged flow with (perhaps cached) refresh token.
386 credentials = storage.get()
387 refreshed = False 452 refreshed = False
388 if credentials and not credentials.invalid: 453 if credentials and not credentials.invalid:
389 try: 454 try:
390 credentials.refresh(httplib2.Http()) 455 credentials.refresh(httplib2.Http())
391 refreshed = True 456 refreshed = True
392 except client.Error as err: 457 except client.Error as err:
393 logging.warning( 458 logging.warning(
394 'OAuth error during access token refresh: %s. ' 459 'OAuth error during access token refresh (%s). '
395 'Attempting a full authentication flow.', err) 460 'Attempting a full authentication flow.', err)
396 461
397 # Refresh token is missing or invalid, go through the full flow. 462 # Refresh token is missing or invalid, go through the full flow.
398 if not refreshed: 463 if not refreshed:
464 # 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.')
399 if not allow_user_interaction: 468 if not allow_user_interaction:
400 raise LoginRequiredError(self._token_cache_key) 469 raise LoginRequiredError(self._token_cache_key)
401 credentials = _run_oauth_dance(self._config) 470 credentials = _run_oauth_dance(self._config)
402 471
403 logging.info( 472 logging.info(
404 'OAuth access_token refreshed. Expires in %s.', 473 'OAuth access_token refreshed. Expires in %s.',
405 credentials.token_expiry - datetime.datetime.utcnow()) 474 credentials.token_expiry - datetime.datetime.utcnow())
475 storage = self._get_storage()
406 credentials.set_store(storage) 476 credentials.set_store(storage)
407 storage.put(credentials) 477 storage.put(credentials)
408 return AccessToken(str(credentials.access_token), credentials.token_expiry) 478 return AccessToken(str(credentials.access_token), credentials.token_expiry)
409 479
410 480
411 ## Private functions. 481 ## Private functions.
412 482
413 483
414 def _should_use_oauth2(): 484 def _should_use_oauth2():
415 """Default value for use_oauth2 config option. 485 """Default value for use_oauth2 config option.
416 486
417 Used to selectively enable OAuth2 by default. 487 Used to selectively enable OAuth2 by default.
418 """ 488 """
419 return os.path.exists(os.path.join(DEPOT_TOOLS_DIR, 'USE_OAUTH2')) 489 return os.path.exists(os.path.join(DEPOT_TOOLS_DIR, 'USE_OAUTH2'))
420 490
421 491
422 def _is_headless(): 492 def _is_headless():
423 """True if machine doesn't seem to have a display.""" 493 """True if machine doesn't seem to have a display."""
424 return sys.platform == 'linux2' and not os.environ.get('DISPLAY') 494 return sys.platform == 'linux2' and not os.environ.get('DISPLAY')
425 495
426 496
497 def _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
427 def _needs_refresh(access_token): 514 def _needs_refresh(access_token):
428 """True if AccessToken should be refreshed.""" 515 """True if AccessToken should be refreshed."""
429 if access_token.expires_at is not None: 516 if access_token.expires_at is not None:
430 # Allow 5 min of clock skew between client and backend. 517 # Allow 5 min of clock skew between client and backend.
431 now = datetime.datetime.utcnow() + datetime.timedelta(seconds=300) 518 now = datetime.datetime.utcnow() + datetime.timedelta(seconds=300)
432 return now >= access_token.expires_at 519 return now >= access_token.expires_at
433 # Token without expiration time never expires. 520 # Token without expiration time never expires.
434 return False 521 return False
435 522
436 523
(...skipping 103 matching lines...) Expand 10 before | Expand all | Expand 10 after
540 self.end_headers() 627 self.end_headers()
541 query = self.path.split('?', 1)[-1] 628 query = self.path.split('?', 1)[-1]
542 query = dict(urlparse.parse_qsl(query)) 629 query = dict(urlparse.parse_qsl(query))
543 self.server.query_params = query 630 self.server.query_params = query
544 self.wfile.write('<html><head><title>Authentication Status</title></head>') 631 self.wfile.write('<html><head><title>Authentication Status</title></head>')
545 self.wfile.write('<body><p>The authentication flow has completed.</p>') 632 self.wfile.write('<body><p>The authentication flow has completed.</p>')
546 self.wfile.write('</body></html>') 633 self.wfile.write('</body></html>')
547 634
548 def log_message(self, _format, *args): 635 def log_message(self, _format, *args):
549 """Do not log messages to stdout while running as command line program.""" 636 """Do not log messages to stdout while running as command line program."""
OLDNEW
« no previous file with comments | « no previous file | tests/git_cl_test.py » ('j') | no next file with comments »

Powered by Google App Engine
This is Rietveld 408576698