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 |