OLD | NEW |
1 # Copyright (c) 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 """Authentication related functions.""" | 5 """Google OAuth2 related functions.""" |
6 | 6 |
| 7 import BaseHTTPServer |
7 import collections | 8 import collections |
| 9 import datetime |
| 10 import functools |
| 11 import json |
| 12 import logging |
8 import optparse | 13 import optparse |
| 14 import os |
| 15 import socket |
| 16 import sys |
| 17 import threading |
| 18 import urllib |
| 19 import urlparse |
| 20 import webbrowser |
| 21 |
| 22 from third_party import httplib2 |
| 23 from third_party.oauth2client import client |
| 24 from third_party.oauth2client import multistore_file |
| 25 |
| 26 |
| 27 # depot_tools/. |
| 28 DEPOT_TOOLS_DIR = os.path.dirname(os.path.abspath(__file__)) |
| 29 |
| 30 |
| 31 # Google OAuth2 clients always have a secret, even if the client is an installed |
| 32 # application/utility such as this. Of course, in such cases the "secret" is |
| 33 # actually publicly known; security depends entirely on the secrecy of refresh |
| 34 # tokens, which effectively become bearer tokens. An attacker can impersonate |
| 35 # service's identity in OAuth2 flow. But that's generally fine as long as a list |
| 36 # of allowed redirect_uri's associated with client_id is limited to 'localhost' |
| 37 # or 'urn:ietf:wg:oauth:2.0:oob'. In that case attacker needs some process |
| 38 # running on user's machine to successfully complete the flow and grab refresh |
| 39 # token. When you have a malicious code running on your machine, you're screwed |
| 40 # anyway. |
| 41 # This particular set is managed by API Console project "chrome-infra-auth". |
| 42 OAUTH_CLIENT_ID = ( |
| 43 '446450136466-2hr92jrq8e6i4tnsa56b52vacp7t3936.apps.googleusercontent.com') |
| 44 OAUTH_CLIENT_SECRET = 'uBfbay2KCy9t4QveJ-dOqHtp' |
| 45 |
| 46 # List of space separated OAuth scopes for generated tokens. GAE apps usually |
| 47 # use userinfo.email scope for authentication. |
| 48 OAUTH_SCOPES = 'https://www.googleapis.com/auth/userinfo.email' |
| 49 |
| 50 # Path to a file with cached OAuth2 credentials used by default. It should be |
| 51 # a safe location accessible only to a current user: knowing content of this |
| 52 # file is roughly equivalent to knowing account password. Single file can hold |
| 53 # multiple independent tokens identified by token_cache_key (see Authenticator). |
| 54 OAUTH_TOKENS_CACHE = os.path.join( |
| 55 os.path.expanduser('~'), '.depot_tools_oauth2_tokens') |
9 | 56 |
10 | 57 |
11 # Authentication configuration extracted from command line options. | 58 # Authentication configuration extracted from command line options. |
12 # See doc string for 'make_auth_config' for meaning of fields. | 59 # See doc string for 'make_auth_config' for meaning of fields. |
13 AuthConfig = collections.namedtuple('AuthConfig', [ | 60 AuthConfig = collections.namedtuple('AuthConfig', [ |
14 'use_oauth2', # deprecated, will be always True | 61 'use_oauth2', # deprecated, will be always True |
15 'save_cookies', # deprecated, will be removed | 62 'save_cookies', # deprecated, will be removed |
16 'use_local_webserver', | 63 'use_local_webserver', |
17 'webserver_port', | 64 'webserver_port', |
18 ]) | 65 ]) |
19 | 66 |
20 | 67 |
| 68 # OAuth access token with its expiration time (UTC datetime or None if unknown). |
| 69 AccessToken = collections.namedtuple('AccessToken', [ |
| 70 'token', |
| 71 'expires_at', |
| 72 ]) |
| 73 |
| 74 |
| 75 class AuthenticationError(Exception): |
| 76 """Raised on errors related to authentication.""" |
| 77 |
| 78 |
| 79 class LoginRequiredError(AuthenticationError): |
| 80 """Interaction with the user is required to authenticate.""" |
| 81 |
| 82 def __init__(self, token_cache_key): |
| 83 # HACK(vadimsh): It is assumed here that the token cache key is a hostname. |
| 84 msg = ( |
| 85 'You are not logged in. Please login first by running:\n' |
| 86 ' depot-tools-auth login %s' % token_cache_key) |
| 87 super(LoginRequiredError, self).__init__(msg) |
| 88 |
| 89 |
21 def make_auth_config( | 90 def make_auth_config( |
22 use_oauth2=None, | 91 use_oauth2=None, |
23 save_cookies=None, | 92 save_cookies=None, |
24 use_local_webserver=None, | 93 use_local_webserver=None, |
25 webserver_port=None): | 94 webserver_port=None): |
26 """Returns new instance of AuthConfig. | 95 """Returns new instance of AuthConfig. |
27 | 96 |
28 If some config option is None, it will be set to a reasonable default value. | 97 If some config option is None, it will be set to a reasonable default value. |
29 This function also acts as an authoritative place for default values of | 98 This function also acts as an authoritative place for default values of |
30 corresponding command line options. | 99 corresponding command line options. |
31 """ | 100 """ |
32 default = lambda val, d: val if val is not None else d | 101 default = lambda val, d: val if val is not None else d |
33 return AuthConfig( | 102 return AuthConfig( |
34 default(use_oauth2, False), | 103 default(use_oauth2, _should_use_oauth2()), |
35 default(save_cookies, True), | 104 default(save_cookies, True), |
36 default(use_local_webserver, True), | 105 default(use_local_webserver, not _is_headless()), |
37 default(webserver_port, 8090)) | 106 default(webserver_port, 8090)) |
38 | 107 |
39 | 108 |
40 def add_auth_options(parser): | 109 def add_auth_options(parser, default_config=None): |
41 """Appends OAuth related options to OptionParser.""" | 110 """Appends OAuth related options to OptionParser.""" |
42 default_config = make_auth_config() | 111 default_config = default_config or make_auth_config() |
43 parser.auth_group = optparse.OptionGroup(parser, 'Auth options') | 112 parser.auth_group = optparse.OptionGroup(parser, 'Auth options') |
| 113 parser.add_option_group(parser.auth_group) |
| 114 |
| 115 # OAuth2 vs password switch. |
| 116 auth_default = 'use OAuth2' if default_config.use_oauth2 else 'use password' |
44 parser.auth_group.add_option( | 117 parser.auth_group.add_option( |
45 '--oauth2', | 118 '--oauth2', |
46 action='store_true', | 119 action='store_true', |
47 dest='use_oauth2', | 120 dest='use_oauth2', |
48 default=default_config.use_oauth2, | 121 default=default_config.use_oauth2, |
49 help='Use OAuth 2.0 instead of a password.') | 122 help='Use OAuth 2.0 instead of a password. [default: %s]' % auth_default) |
50 parser.auth_group.add_option( | 123 parser.auth_group.add_option( |
51 '--no-oauth2', | 124 '--no-oauth2', |
52 action='store_false', | 125 action='store_false', |
53 dest='use_oauth2', | 126 dest='use_oauth2', |
54 default=default_config.use_oauth2, | 127 default=default_config.use_oauth2, |
55 help='Use password instead of OAuth 2.0.') | 128 help='Use password instead of OAuth 2.0. [default: %s]' % auth_default) |
| 129 |
| 130 # Password related options, deprecated. |
56 parser.auth_group.add_option( | 131 parser.auth_group.add_option( |
57 '--no-cookies', | 132 '--no-cookies', |
58 action='store_false', | 133 action='store_false', |
59 dest='save_cookies', | 134 dest='save_cookies', |
60 default=default_config.save_cookies, | 135 default=default_config.save_cookies, |
61 help='Do not save authentication cookies to local disk.') | 136 help='Do not save authentication cookies to local disk.') |
| 137 |
| 138 # OAuth2 related options. |
62 parser.auth_group.add_option( | 139 parser.auth_group.add_option( |
63 '--auth-no-local-webserver', | 140 '--auth-no-local-webserver', |
64 action='store_false', | 141 action='store_false', |
65 dest='use_local_webserver', | 142 dest='use_local_webserver', |
66 default=default_config.use_local_webserver, | 143 default=default_config.use_local_webserver, |
67 help='Do not run a local web server when performing OAuth2 login flow.') | 144 help='Do not run a local web server when performing OAuth2 login flow.') |
68 parser.auth_group.add_option( | 145 parser.auth_group.add_option( |
69 '--auth-host-port', | 146 '--auth-host-port', |
70 type=int, | 147 type=int, |
71 default=default_config.webserver_port, | 148 default=default_config.webserver_port, |
72 help='Port a local web server should listen on. Used only if ' | 149 help='Port a local web server should listen on. Used only if ' |
73 '--auth-no-local-webserver is not set. [default: %default]') | 150 '--auth-no-local-webserver is not set. [default: %default]') |
74 parser.add_option_group(parser.auth_group) | |
75 | 151 |
76 | 152 |
77 def extract_auth_config_from_options(options): | 153 def extract_auth_config_from_options(options): |
78 """Given OptionParser parsed options, extracts AuthConfig from it. | 154 """Given OptionParser parsed options, extracts AuthConfig from it. |
79 | 155 |
80 OptionParser should be populated with auth options by 'add_auth_options'. | 156 OptionParser should be populated with auth options by 'add_auth_options'. |
81 """ | 157 """ |
82 return make_auth_config( | 158 return make_auth_config( |
83 use_oauth2=options.use_oauth2, | 159 use_oauth2=options.use_oauth2, |
84 save_cookies=False if options.use_oauth2 else options.save_cookies, | 160 save_cookies=False if options.use_oauth2 else options.save_cookies, |
85 use_local_webserver=options.use_local_webserver, | 161 use_local_webserver=options.use_local_webserver, |
86 webserver_port=options.auth_host_port) | 162 webserver_port=options.auth_host_port) |
87 | 163 |
88 | 164 |
89 def auth_config_to_command_options(auth_config): | 165 def auth_config_to_command_options(auth_config): |
90 """AuthConfig -> list of strings with command line options.""" | 166 """AuthConfig -> list of strings with command line options. |
| 167 |
| 168 Omits options that are set to default values. |
| 169 """ |
91 if not auth_config: | 170 if not auth_config: |
92 return [] | 171 return [] |
93 opts = ['--oauth2' if auth_config.use_oauth2 else '--no-oauth2'] | 172 defaults = make_auth_config() |
94 if not auth_config.save_cookies: | 173 opts = [] |
95 opts.append('--no-cookies') | 174 if auth_config.use_oauth2 != defaults.use_oauth2: |
96 if not auth_config.use_local_webserver: | 175 opts.append('--oauth2' if auth_config.use_oauth2 else '--no-oauth2') |
97 opts.append('--auth-no-local-webserver') | 176 if auth_config.save_cookies != auth_config.save_cookies: |
98 opts.extend(['--auth-host-port', str(auth_config.webserver_port)]) | 177 if not auth_config.save_cookies: |
| 178 opts.append('--no-cookies') |
| 179 if auth_config.use_local_webserver != defaults.use_local_webserver: |
| 180 if not auth_config.use_local_webserver: |
| 181 opts.append('--auth-no-local-webserver') |
| 182 if auth_config.webserver_port != defaults.webserver_port: |
| 183 opts.extend(['--auth-host-port', str(auth_config.webserver_port)]) |
99 return opts | 184 return opts |
| 185 |
| 186 |
| 187 def get_authenticator_for_host(hostname, config): |
| 188 """Returns Authenticator instance to access given host. |
| 189 |
| 190 Args: |
| 191 hostname: a naked hostname or http(s)://<hostname>[/] URL. Used to derive |
| 192 a cache key for token cache. |
| 193 config: AuthConfig instance. |
| 194 |
| 195 Returns: |
| 196 Authenticator object. |
| 197 """ |
| 198 hostname = hostname.lower().rstrip('/') |
| 199 # Append some scheme, otherwise urlparse puts hostname into parsed.path. |
| 200 if '://' not in hostname: |
| 201 hostname = 'https://' + hostname |
| 202 parsed = urlparse.urlparse(hostname) |
| 203 if parsed.path or parsed.params or parsed.query or parsed.fragment: |
| 204 raise AuthenticationError( |
| 205 'Expecting a hostname or root host URL, got %s instead' % hostname) |
| 206 return Authenticator(parsed.netloc, config) |
| 207 |
| 208 |
| 209 class Authenticator(object): |
| 210 """Object that knows how to refresh access tokens when needed. |
| 211 |
| 212 Args: |
| 213 token_cache_key: string key of a section of the token cache file to use |
| 214 to keep the tokens. See hostname_to_token_cache_key. |
| 215 config: AuthConfig object that holds authentication configuration. |
| 216 """ |
| 217 |
| 218 def __init__(self, token_cache_key, config): |
| 219 assert isinstance(config, AuthConfig) |
| 220 assert config.use_oauth2 |
| 221 self._access_token = None |
| 222 self._config = config |
| 223 self._lock = threading.Lock() |
| 224 self._token_cache_key = token_cache_key |
| 225 |
| 226 def login(self): |
| 227 """Performs interactive login flow if necessary. |
| 228 |
| 229 Raises: |
| 230 AuthenticationError on error or if interrupted. |
| 231 """ |
| 232 return self.get_access_token( |
| 233 force_refresh=True, allow_user_interaction=True) |
| 234 |
| 235 def logout(self): |
| 236 """Revokes the refresh token and deletes it from the cache. |
| 237 |
| 238 Returns True if actually revoked a token. |
| 239 """ |
| 240 revoked = False |
| 241 with self._lock: |
| 242 self._access_token = None |
| 243 storage = self._get_storage() |
| 244 credentials = storage.get() |
| 245 if credentials: |
| 246 credentials.revoke(httplib2.Http()) |
| 247 revoked = True |
| 248 storage.delete() |
| 249 return revoked |
| 250 |
| 251 def has_cached_credentials(self): |
| 252 """Returns True if long term credentials (refresh token) are in cache. |
| 253 |
| 254 Doesn't make network calls. |
| 255 |
| 256 If returns False, get_access_token() later will ask for interactive login by |
| 257 raising LoginRequiredError. |
| 258 |
| 259 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 |
| 261 revoked and there's no way to figure this out without actually trying to use |
| 262 it. |
| 263 """ |
| 264 with self._lock: |
| 265 credentials = self._get_storage().get() |
| 266 return credentials and not credentials.invalid |
| 267 |
| 268 def get_access_token(self, force_refresh=False, allow_user_interaction=False): |
| 269 """Returns AccessToken, refreshing it if necessary. |
| 270 |
| 271 Args: |
| 272 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. |
| 274 |
| 275 Raises: |
| 276 AuthenticationError on error or if authentication flow was interrupted. |
| 277 LoginRequiredError if user interaction is required, but |
| 278 allow_user_interaction is False. |
| 279 """ |
| 280 with self._lock: |
| 281 if force_refresh: |
| 282 self._access_token = self._create_access_token(allow_user_interaction) |
| 283 return self._access_token |
| 284 |
| 285 # Load from on-disk cache on a first access. |
| 286 if not self._access_token: |
| 287 self._access_token = self._load_access_token() |
| 288 |
| 289 # Refresh if expired or missing. |
| 290 if not self._access_token or _needs_refresh(self._access_token): |
| 291 # Maybe some other process already updated it, reload from the cache. |
| 292 self._access_token = self._load_access_token() |
| 293 # Nope, still expired, need to run the refresh flow. |
| 294 if not self._access_token or _needs_refresh(self._access_token): |
| 295 self._access_token = self._create_access_token(allow_user_interaction) |
| 296 |
| 297 return self._access_token |
| 298 |
| 299 def get_token_info(self): |
| 300 """Returns a result of /oauth2/v2/tokeninfo call with token info.""" |
| 301 access_token = self.get_access_token() |
| 302 resp, content = httplib2.Http().request( |
| 303 uri='https://www.googleapis.com/oauth2/v2/tokeninfo?%s' % ( |
| 304 urllib.urlencode({'access_token': access_token.token}))) |
| 305 if resp.status == 200: |
| 306 return json.loads(content) |
| 307 raise AuthenticationError('Failed to fetch the token info: %r' % content) |
| 308 |
| 309 def authorize(self, http): |
| 310 """Monkey patches authentication logic of httplib2.Http instance. |
| 311 |
| 312 The modified http.request method will add authentication headers to each |
| 313 request and will refresh access_tokens when a 401 is received on a |
| 314 request. |
| 315 |
| 316 Args: |
| 317 http: An instance of httplib2.Http. |
| 318 |
| 319 Returns: |
| 320 A modified instance of http that was passed in. |
| 321 """ |
| 322 # Adapted from oauth2client.OAuth2Credentials.authorize. |
| 323 |
| 324 request_orig = http.request |
| 325 |
| 326 @functools.wraps(request_orig) |
| 327 def new_request( |
| 328 uri, method='GET', body=None, headers=None, |
| 329 redirections=httplib2.DEFAULT_MAX_REDIRECTS, |
| 330 connection_type=None): |
| 331 headers = (headers or {}).copy() |
| 332 headers['Authorizaton'] = 'Bearer %s' % self.get_access_token().token |
| 333 resp, content = request_orig( |
| 334 uri, method, body, headers, redirections, connection_type) |
| 335 if resp.status in client.REFRESH_STATUS_CODES: |
| 336 logging.info('Refreshing due to a %s', resp.status) |
| 337 access_token = self.get_access_token(force_refresh=True) |
| 338 headers['Authorizaton'] = 'Bearer %s' % access_token.token |
| 339 return request_orig( |
| 340 uri, method, body, headers, redirections, connection_type) |
| 341 else: |
| 342 return (resp, content) |
| 343 |
| 344 http.request = new_request |
| 345 return http |
| 346 |
| 347 ## Private methods. |
| 348 |
| 349 def _get_storage(self): |
| 350 """Returns oauth2client.Storage with cached tokens.""" |
| 351 return multistore_file.get_credential_storage_custom_string_key( |
| 352 OAUTH_TOKENS_CACHE, self._token_cache_key) |
| 353 |
| 354 def _load_access_token(self): |
| 355 """Returns cached AccessToken if it is not expired yet.""" |
| 356 credentials = self._get_storage().get() |
| 357 if not credentials or credentials.invalid: |
| 358 return None |
| 359 if not credentials.access_token or credentials.access_token_expired: |
| 360 return None |
| 361 return AccessToken(credentials.access_token, credentials.token_expiry) |
| 362 |
| 363 def _create_access_token(self, allow_user_interaction=False): |
| 364 """Mints and caches a new access token, launching OAuth2 dance if necessary. |
| 365 |
| 366 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 |
| 368 OAuth2 flow, that needs user interaction. |
| 369 |
| 370 Args: |
| 371 allow_user_interaction: if True, allow interaction with the user (e.g. |
| 372 reading standard input, or launching a browser). |
| 373 |
| 374 Returns: |
| 375 AccessToken. |
| 376 |
| 377 Raises: |
| 378 AuthenticationError on error or if authentication flow was interrupted. |
| 379 LoginRequiredError if user interaction is required, but |
| 380 allow_user_interaction is False. |
| 381 """ |
| 382 storage = self._get_storage() |
| 383 credentials = None |
| 384 |
| 385 # 3-legged flow with (perhaps cached) refresh token. |
| 386 credentials = storage.get() |
| 387 refreshed = False |
| 388 if credentials and not credentials.invalid: |
| 389 try: |
| 390 credentials.refresh(httplib2.Http()) |
| 391 refreshed = True |
| 392 except client.Error as err: |
| 393 logging.warning( |
| 394 'OAuth error during access token refresh: %s. ' |
| 395 'Attempting a full authentication flow.', err) |
| 396 |
| 397 # Refresh token is missing or invalid, go through the full flow. |
| 398 if not refreshed: |
| 399 if not allow_user_interaction: |
| 400 raise LoginRequiredError(self._token_cache_key) |
| 401 credentials = _run_oauth_dance(self._config) |
| 402 |
| 403 logging.info( |
| 404 'OAuth access_token refreshed. Expires in %s.', |
| 405 credentials.token_expiry - datetime.datetime.utcnow()) |
| 406 credentials.set_store(storage) |
| 407 storage.put(credentials) |
| 408 return AccessToken(credentials.access_token, credentials.token_expiry) |
| 409 |
| 410 |
| 411 ## Private functions. |
| 412 |
| 413 |
| 414 def _should_use_oauth2(): |
| 415 """Default value for use_oauth2 config option. |
| 416 |
| 417 Used to selectively enable OAuth2 by default. |
| 418 """ |
| 419 return os.path.exists(os.path.join(DEPOT_TOOLS_DIR, 'USE_OAUTH2')) |
| 420 |
| 421 |
| 422 def _is_headless(): |
| 423 """True if machine doesn't seem to have a display.""" |
| 424 return sys.platform == 'linux2' and not os.environ.get('DISPLAY') |
| 425 |
| 426 |
| 427 def _needs_refresh(access_token): |
| 428 """True if AccessToken should be refreshed.""" |
| 429 if access_token.expires_at is not None: |
| 430 # Allow 5 min of clock skew between client and backend. |
| 431 now = datetime.datetime.utcnow() + datetime.timedelta(seconds=300) |
| 432 return now >= access_token.expires_at |
| 433 # Token without expiration time never expires. |
| 434 return False |
| 435 |
| 436 |
| 437 def _run_oauth_dance(config): |
| 438 """Perform full 3-legged OAuth2 flow with the browser. |
| 439 |
| 440 Returns: |
| 441 oauth2client.Credentials. |
| 442 |
| 443 Raises: |
| 444 AuthenticationError on errors. |
| 445 """ |
| 446 flow = client.OAuth2WebServerFlow( |
| 447 OAUTH_CLIENT_ID, |
| 448 OAUTH_CLIENT_SECRET, |
| 449 OAUTH_SCOPES, |
| 450 approval_prompt='force') |
| 451 |
| 452 use_local_webserver = config.use_local_webserver |
| 453 port = config.webserver_port |
| 454 if config.use_local_webserver: |
| 455 success = False |
| 456 try: |
| 457 httpd = _ClientRedirectServer(('localhost', port), _ClientRedirectHandler) |
| 458 except socket.error: |
| 459 pass |
| 460 else: |
| 461 success = True |
| 462 use_local_webserver = success |
| 463 if not success: |
| 464 print( |
| 465 'Failed to start a local webserver listening on port %d.\n' |
| 466 'Please check your firewall settings and locally running programs that ' |
| 467 'may be blocking or using those ports.\n\n' |
| 468 'Falling back to --auth-no-local-webserver and continuing with ' |
| 469 'authentication.\n' % port) |
| 470 |
| 471 if use_local_webserver: |
| 472 oauth_callback = 'http://localhost:%s/' % port |
| 473 else: |
| 474 oauth_callback = client.OOB_CALLBACK_URN |
| 475 flow.redirect_uri = oauth_callback |
| 476 authorize_url = flow.step1_get_authorize_url() |
| 477 |
| 478 if use_local_webserver: |
| 479 webbrowser.open(authorize_url, new=1, autoraise=True) |
| 480 print( |
| 481 'Your browser has been opened to visit:\n\n' |
| 482 ' %s\n\n' |
| 483 'If your browser is on a different machine then exit and re-run this ' |
| 484 'application with the command-line parameter\n\n' |
| 485 ' --auth-no-local-webserver\n' % authorize_url) |
| 486 else: |
| 487 print( |
| 488 'Go to the following link in your browser:\n\n' |
| 489 ' %s\n' % authorize_url) |
| 490 |
| 491 try: |
| 492 code = None |
| 493 if use_local_webserver: |
| 494 httpd.handle_request() |
| 495 if 'error' in httpd.query_params: |
| 496 raise AuthenticationError( |
| 497 'Authentication request was rejected: %s' % |
| 498 httpd.query_params['error']) |
| 499 if 'code' not in httpd.query_params: |
| 500 raise AuthenticationError( |
| 501 'Failed to find "code" in the query parameters of the redirect.\n' |
| 502 'Try running with --auth-no-local-webserver.') |
| 503 code = httpd.query_params['code'] |
| 504 else: |
| 505 code = raw_input('Enter verification code: ').strip() |
| 506 except KeyboardInterrupt: |
| 507 raise AuthenticationError('Authentication was canceled.') |
| 508 |
| 509 try: |
| 510 return flow.step2_exchange(code) |
| 511 except client.FlowExchangeError as e: |
| 512 raise AuthenticationError('Authentication has failed: %s' % e) |
| 513 |
| 514 |
| 515 class _ClientRedirectServer(BaseHTTPServer.HTTPServer): |
| 516 """A server to handle OAuth 2.0 redirects back to localhost. |
| 517 |
| 518 Waits for a single request and parses the query parameters |
| 519 into query_params and then stops serving. |
| 520 """ |
| 521 query_params = {} |
| 522 |
| 523 |
| 524 class _ClientRedirectHandler(BaseHTTPServer.BaseHTTPRequestHandler): |
| 525 """A handler for OAuth 2.0 redirects back to localhost. |
| 526 |
| 527 Waits for a single request and parses the query parameters |
| 528 into the servers query_params and then stops serving. |
| 529 """ |
| 530 |
| 531 def do_GET(self): |
| 532 """Handle a GET request. |
| 533 |
| 534 Parses the query parameters and prints a message |
| 535 if the flow has completed. Note that we can't detect |
| 536 if an error occurred. |
| 537 """ |
| 538 self.send_response(200) |
| 539 self.send_header('Content-type', 'text/html') |
| 540 self.end_headers() |
| 541 query = self.path.split('?', 1)[-1] |
| 542 query = dict(urlparse.parse_qsl(query)) |
| 543 self.server.query_params = query |
| 544 self.wfile.write('<html><head><title>Authentication Status</title></head>') |
| 545 self.wfile.write('<body><p>The authentication flow has completed.</p>') |
| 546 self.wfile.write('</body></html>') |
| 547 |
| 548 def log_message(self, _format, *args): |
| 549 """Do not log messages to stdout while running as command line program.""" |
OLD | NEW |