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