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

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 if credentials and credentials.refresh_token and credentials.revoke_uri:
246 credentials.revoke(httplib2.Http()) 271 try:
247 revoked = True 272 credentials.revoke(httplib2.Http())
273 except client.TokenRevokeError as e:
274 logging.warning('Failed to revoke refresh token: %s', e)
M-A Ruel 2015/04/17 01:49:50 you don't set credentials to None in else / except
Vadim Sh. 2015/04/17 02:38:07 If cached refresh token is invalid (for example re
248 storage.delete() 275 storage.delete()
249 return revoked 276 return bool(credentials)
250 277
251 def has_cached_credentials(self): 278 def has_cached_credentials(self):
252 """Returns True if long term credentials (refresh token) are in cache. 279 """Returns True if long term credentials (refresh token) are in cache.
253 280
254 Doesn't make network calls. 281 Doesn't make network calls.
255 282
256 If returns False, get_access_token() later will ask for interactive login by 283 If returns False, get_access_token() later will ask for interactive login by
257 raising LoginRequiredError. 284 raising LoginRequiredError.
258 285
259 If returns True, most probably get_access_token() won't ask for interactive 286 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 287 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 288 revoked and there's no way to figure this out without actually trying to use
262 it. 289 it.
263 """ 290 """
264 with self._lock: 291 with self._lock:
265 credentials = self._get_storage().get() 292 return bool(self._get_cached_credentials())
266 return credentials and not credentials.invalid
267 293
268 def get_access_token(self, force_refresh=False, allow_user_interaction=False): 294 def get_access_token(self, force_refresh=False, allow_user_interaction=False):
269 """Returns AccessToken, refreshing it if necessary. 295 """Returns AccessToken, refreshing it if necessary.
270 296
271 Args: 297 Args:
272 force_refresh: forcefully refresh access token even if it is not expired. 298 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. 299 allow_user_interaction: True to enable blocking for user input if needed.
274 300
275 Raises: 301 Raises:
276 AuthenticationError on error or if authentication flow was interrupted. 302 AuthenticationError on error or if authentication flow was interrupted.
(...skipping 64 matching lines...) Expand 10 before | Expand all | Expand 10 after
341 else: 367 else:
342 return (resp, content) 368 return (resp, content)
343 369
344 http.request = new_request 370 http.request = new_request
345 return http 371 return http
346 372
347 ## Private methods. 373 ## Private methods.
348 374
349 def _get_storage(self): 375 def _get_storage(self):
350 """Returns oauth2client.Storage with cached tokens.""" 376 """Returns oauth2client.Storage with cached tokens."""
377 # Do not mix cache keys for different externally provided tokens.
378 if self._external_token:
379 token_hash = hashlib.sha1(self._external_token.refresh_token).hexdigest()
380 cache_key = '%s:refresh_tok:%s' % (self._token_cache_key, token_hash)
381 else:
382 cache_key = self._token_cache_key
351 return multistore_file.get_credential_storage_custom_string_key( 383 return multistore_file.get_credential_storage_custom_string_key(
352 OAUTH_TOKENS_CACHE, self._token_cache_key) 384 OAUTH_TOKENS_CACHE, cache_key)
385
386 def _get_cached_credentials(self):
387 """Returns oauth2client.Credentials loaded from storage."""
388 storage = self._get_storage()
389 credentials = storage.get()
390
391 # Is using --auth-refresh-token-json?
392 if self._external_token:
393 # Cached credentials are valid and match external token -> use them. It is
394 # important to reuse credentials from the storage because they contain
395 # cached access token.
396 valid = (
397 credentials and not credentials.invalid and
398 credentials.refresh_token == self._external_token.refresh_token and
399 credentials.client_id == self._external_token.client_id and
400 credentials.client_secret == self._external_token.client_secret)
401 if valid:
402 return credentials
403 # Construct new credentials from externally provided refresh token,
404 # associate them with cache storage (so that access_token will be placed
405 # in the cache later too).
406 credentials = client.OAuth2Credentials(
407 access_token=None,
408 client_id=self._external_token.client_id,
409 client_secret=self._external_token.client_secret,
410 refresh_token=self._external_token.refresh_token,
411 token_expiry=None,
412 token_uri='https://accounts.google.com/o/oauth2/token',
413 user_agent=None,
414 revoke_uri=None)
415 credentials.set_store(storage)
416 storage.put(credentials)
417 return credentials
418
419 # Not using external refresh token -> return whatever is cached.
420 return credentials if (credentials and not credentials.invalid) else None
353 421
354 def _load_access_token(self): 422 def _load_access_token(self):
355 """Returns cached AccessToken if it is not expired yet.""" 423 """Returns cached AccessToken if it is not expired yet."""
356 credentials = self._get_storage().get() 424 creds = self._get_cached_credentials()
357 if not credentials or credentials.invalid: 425 if not creds or not creds.access_token or creds.access_token_expired:
358 return None 426 return None
359 if not credentials.access_token or credentials.access_token_expired: 427 return AccessToken(str(creds.access_token), creds.token_expiry)
360 return None
361 return AccessToken(str(credentials.access_token), credentials.token_expiry)
362 428
363 def _create_access_token(self, allow_user_interaction=False): 429 def _create_access_token(self, allow_user_interaction=False):
364 """Mints and caches a new access token, launching OAuth2 dance if necessary. 430 """Mints and caches a new access token, launching OAuth2 dance if necessary.
365 431
366 Uses cached refresh token, if present. In that case user interaction is not 432 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 433 required and function will finish quietly. Otherwise it will launch 3-legged
368 OAuth2 flow, that needs user interaction. 434 OAuth2 flow, that needs user interaction.
369 435
370 Args: 436 Args:
371 allow_user_interaction: if True, allow interaction with the user (e.g. 437 allow_user_interaction: if True, allow interaction with the user (e.g.
372 reading standard input, or launching a browser). 438 reading standard input, or launching a browser).
373 439
374 Returns: 440 Returns:
375 AccessToken. 441 AccessToken.
376 442
377 Raises: 443 Raises:
378 AuthenticationError on error or if authentication flow was interrupted. 444 AuthenticationError on error or if authentication flow was interrupted.
379 LoginRequiredError if user interaction is required, but 445 LoginRequiredError if user interaction is required, but
380 allow_user_interaction is False. 446 allow_user_interaction is False.
381 """ 447 """
382 storage = self._get_storage() 448 credentials = self._get_cached_credentials()
383 credentials = None
384 449
385 # 3-legged flow with (perhaps cached) refresh token. 450 # 3-legged flow with (perhaps cached) refresh token.
386 credentials = storage.get()
387 refreshed = False 451 refreshed = False
388 if credentials and not credentials.invalid: 452 if credentials and not credentials.invalid:
389 try: 453 try:
390 credentials.refresh(httplib2.Http()) 454 credentials.refresh(httplib2.Http())
391 refreshed = True 455 refreshed = True
392 except client.Error as err: 456 except client.Error as err:
393 logging.warning( 457 logging.warning(
394 'OAuth error during access token refresh: %s. ' 458 'OAuth error during access token refresh (%s). '
395 'Attempting a full authentication flow.', err) 459 'Attempting a full authentication flow.', err)
396 460
397 # Refresh token is missing or invalid, go through the full flow. 461 # Refresh token is missing or invalid, go through the full flow.
398 if not refreshed: 462 if not refreshed:
463 # Can't refresh externally provided token.
464 if self._external_token:
465 raise AuthenticationError(
466 'Token provided via --auth-refresh-token-json is no longer valid.')
399 if not allow_user_interaction: 467 if not allow_user_interaction:
400 raise LoginRequiredError(self._token_cache_key) 468 raise LoginRequiredError(self._token_cache_key)
401 credentials = _run_oauth_dance(self._config) 469 credentials = _run_oauth_dance(self._config)
402 470
403 logging.info( 471 logging.info(
404 'OAuth access_token refreshed. Expires in %s.', 472 'OAuth access_token refreshed. Expires in %s.',
405 credentials.token_expiry - datetime.datetime.utcnow()) 473 credentials.token_expiry - datetime.datetime.utcnow())
474 storage = self._get_storage()
406 credentials.set_store(storage) 475 credentials.set_store(storage)
407 storage.put(credentials) 476 storage.put(credentials)
408 return AccessToken(str(credentials.access_token), credentials.token_expiry) 477 return AccessToken(str(credentials.access_token), credentials.token_expiry)
409 478
410 479
411 ## Private functions. 480 ## Private functions.
412 481
413 482
414 def _should_use_oauth2(): 483 def _should_use_oauth2():
415 """Default value for use_oauth2 config option. 484 """Default value for use_oauth2 config option.
416 485
417 Used to selectively enable OAuth2 by default. 486 Used to selectively enable OAuth2 by default.
418 """ 487 """
419 return os.path.exists(os.path.join(DEPOT_TOOLS_DIR, 'USE_OAUTH2')) 488 return os.path.exists(os.path.join(DEPOT_TOOLS_DIR, 'USE_OAUTH2'))
420 489
421 490
422 def _is_headless(): 491 def _is_headless():
423 """True if machine doesn't seem to have a display.""" 492 """True if machine doesn't seem to have a display."""
424 return sys.platform == 'linux2' and not os.environ.get('DISPLAY') 493 return sys.platform == 'linux2' and not os.environ.get('DISPLAY')
425 494
426 495
496 def _read_refresh_token_json(path):
497 """Returns RefreshToken by reading it from the JSON file."""
498 try:
499 with open(path, 'r') as f:
500 data = json.load(f)
501 return RefreshToken(
502 client_id=str(data.get('client_id', OAUTH_CLIENT_ID)),
503 client_secret=str(data.get('client_secret', OAUTH_CLIENT_SECRET)),
504 refresh_token=str(data['refresh_token']))
505 except (IOError, ValueError) as e:
506 raise AuthenticationError(
507 'Failed to read refresh token from %s: %s' % (path, e))
508 except KeyError as e:
509 raise AuthenticationError(
510 'Failed to read refresh token from %s: missing key %s' % (path, e))
511
512
427 def _needs_refresh(access_token): 513 def _needs_refresh(access_token):
428 """True if AccessToken should be refreshed.""" 514 """True if AccessToken should be refreshed."""
429 if access_token.expires_at is not None: 515 if access_token.expires_at is not None:
430 # Allow 5 min of clock skew between client and backend. 516 # Allow 5 min of clock skew between client and backend.
431 now = datetime.datetime.utcnow() + datetime.timedelta(seconds=300) 517 now = datetime.datetime.utcnow() + datetime.timedelta(seconds=300)
432 return now >= access_token.expires_at 518 return now >= access_token.expires_at
433 # Token without expiration time never expires. 519 # Token without expiration time never expires.
434 return False 520 return False
435 521
436 522
(...skipping 103 matching lines...) Expand 10 before | Expand all | Expand 10 after
540 self.end_headers() 626 self.end_headers()
541 query = self.path.split('?', 1)[-1] 627 query = self.path.split('?', 1)[-1]
542 query = dict(urlparse.parse_qsl(query)) 628 query = dict(urlparse.parse_qsl(query))
543 self.server.query_params = query 629 self.server.query_params = query
544 self.wfile.write('<html><head><title>Authentication Status</title></head>') 630 self.wfile.write('<html><head><title>Authentication Status</title></head>')
545 self.wfile.write('<body><p>The authentication flow has completed.</p>') 631 self.wfile.write('<body><p>The authentication flow has completed.</p>')
546 self.wfile.write('</body></html>') 632 self.wfile.write('</body></html>')
547 633
548 def log_message(self, _format, *args): 634 def log_message(self, _format, *args):
549 """Do not log messages to stdout while running as command line program.""" 635 """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