Upstream version 10.39.225.0
[platform/framework/web/crosswalk.git] / src / tools / swarming_client / utils / oauth.py
1 # Copyright 2013 The Swarming Authors. All rights reserved.
2 # Use of this source code is governed under the Apache License, Version 2.0 that
3 # can be found in the LICENSE file.
4
5 """OAuth2 related utilities and implementation of browser based login flow."""
6
7 # pylint: disable=W0613
8
9 import BaseHTTPServer
10 import collections
11 import datetime
12 import logging
13 import optparse
14 import os
15 import socket
16 import sys
17 import threading
18 import urlparse
19 import webbrowser
20
21 # oauth2client expects to find itself in sys.path.
22 # Also ensure we use bundled version instead of system one.
23 ROOT_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
24 sys.path.insert(0, os.path.join(ROOT_DIR, 'third_party'))
25
26 import httplib2
27 from oauth2client import client
28 from oauth2client import multistore_file
29
30 from third_party import requests
31 from utils import tools
32
33
34 # Path to a file with cached OAuth2 credentials used by default. Can be
35 # overridden by command line option or env variable.
36 DEFAULT_OAUTH_TOKENS_CACHE = os.path.join(
37     os.path.expanduser('~'), '.isolated_oauth')
38
39
40 # OAuth authentication method configuration, used by utils/net.py.
41 # See doc string for 'make_oauth_config' for meaning of fields.
42 OAuthConfig = collections.namedtuple('OAuthConfig', [
43   'tokens_cache',
44   'no_local_webserver',
45   'webserver_port',
46 ])
47
48
49 # Configuration fetched from a service, returned by _fetch_service_config.
50 _ServiceConfig = collections.namedtuple('_ServiceConfig', [
51   'client_id',
52   'client_secret',
53   'primary_url',
54 ])
55
56 # Process cache of _fetch_service_config results.
57 _service_config_cache = {}
58 _service_config_cache_lock = threading.Lock()
59
60
61 def make_oauth_config(
62     tokens_cache=None, no_local_webserver=None, webserver_port=None):
63   """Returns new instance of OAuthConfig.
64
65   If some config option is not provided or None, it will be set to a reasonable
66   default value. This function also acts as an authoritative place for default
67   values of corresponding command line options.
68
69   Args:
70     tokens_cache: path to a file with cached OAuth2 credentials.
71     no_local_webserver: if True, do not try to run local web server that
72         handles redirects. Use copy-pasted verification code instead.
73     webserver_port: port to run local webserver on.
74   """
75   if tokens_cache is None:
76     tokens_cache = os.environ.get(
77         'SWARMING_AUTH_TOKENS_CACHE', DEFAULT_OAUTH_TOKENS_CACHE)
78   if no_local_webserver is None:
79     no_local_webserver = tools.get_bool_env_var(
80         'SWARMING_AUTH_NO_LOCAL_WEBSERVER')
81   # TODO(vadimsh): Add support for "find free port" option.
82   if webserver_port is None:
83     webserver_port = 8090
84   return OAuthConfig(tokens_cache, no_local_webserver, webserver_port)
85
86
87 def add_oauth_options(parser):
88   """Appends OAuth related options to OptionParser."""
89   default_config = make_oauth_config()
90   parser.oauth_group = optparse.OptionGroup(
91       parser, 'OAuth options [used if --auth-method=oauth]')
92   parser.oauth_group.add_option(
93       '--auth-tokens-cache',
94       default=default_config.tokens_cache,
95       help='Path to a file to keep OAuth2 tokens cache. It should be a safe '
96           'location accessible only to a current user: knowing content of this '
97           'file is roughly equivalent to knowing account password. Can also be '
98           'set with SWARMING_AUTH_TOKENS_CACHE environment variable. '
99           '[default: %default]')
100   parser.oauth_group.add_option(
101       '--auth-no-local-webserver',
102       action='store_true',
103       default=default_config.no_local_webserver,
104       help='Do not run a local web server when performing OAuth2 login flow. '
105           'Can also be set with SWARMING_AUTH_NO_LOCAL_WEBSERVER=1 '
106           'environment variable. [default: %default]')
107   parser.oauth_group.add_option(
108       '--auth-host-port',
109       type=int,
110       default=default_config.webserver_port,
111       help='Port a local web server should listen on. Used only if '
112           '--auth-no-local-webserver is not set. [default: %default]')
113   parser.add_option_group(parser.oauth_group)
114
115
116 def extract_oauth_config_from_options(options):
117   """Given OptionParser with oauth options, extracts OAuthConfig from it.
118
119   OptionParser should be populated with oauth options by 'add_oauth_options'.
120   """
121   return make_oauth_config(
122       tokens_cache=options.auth_tokens_cache,
123       no_local_webserver=options.auth_no_local_webserver,
124       webserver_port=options.auth_host_port)
125
126
127 def load_access_token(urlhost, config):
128   """Returns cached access token if it is not expired yet."""
129   assert isinstance(config, OAuthConfig)
130   auth_service_url = _fetch_auth_service_url(urlhost)
131   if not auth_service_url:
132     return None
133   storage = _get_storage(auth_service_url, config)
134   credentials = storage.get()
135   # Missing?
136   if not credentials or credentials.invalid:
137     return None
138   # Expired?
139   if not credentials.access_token or credentials.access_token_expired:
140     return None
141   return credentials.access_token
142
143
144 def create_access_token(urlhost, config, allow_user_interaction):
145   """Mints and caches new access_token, launching OAuth2 dance if necessary.
146
147   Args:
148     urlhost: base URL of a host to make OAuth2 token for.
149     config: OAuthConfig instance.
150     allow_user_interaction: if False, do not use interactive browser based
151         flow (return None instead if it is required).
152
153   Returns:
154     access_token on success.
155     None on error or if OAuth2 flow was interrupted.
156   """
157   assert isinstance(config, OAuthConfig)
158   auth_service_url = _fetch_auth_service_url(urlhost)
159   if not auth_service_url:
160     return None
161   storage = _get_storage(auth_service_url, config)
162   credentials = storage.get()
163
164   # refresh_token is missing, need to go through full flow.
165   if credentials is None or credentials.invalid:
166     if allow_user_interaction:
167       return _run_oauth_dance(auth_service_url, storage, config)
168     return None
169
170   # refresh_token is ok, use it.
171   try:
172     credentials.refresh(httplib2.Http(ca_certs=tools.get_cacerts_bundle()))
173   except client.Error as err:
174     logging.error('OAuth error: %s', err)
175     if allow_user_interaction:
176       return _run_oauth_dance(auth_service_url, storage, config)
177     return None
178
179   # Success.
180   logging.info('OAuth access_token refreshed. Expires in %s.',
181       credentials.token_expiry - datetime.datetime.utcnow())
182   storage.put(credentials)
183   return credentials.access_token
184
185
186 def purge_access_token(urlhost, config):
187   """Deletes OAuth tokens that can be used to access |urlhost|."""
188   assert isinstance(config, OAuthConfig)
189   auth_service_url = _fetch_auth_service_url(urlhost)
190   if auth_service_url:
191     _get_storage(auth_service_url, config).delete()
192
193
194 def _get_storage(urlhost, config):
195   """Returns oauth2client.Storage with tokens to access |urlhost|."""
196   return multistore_file.get_credential_storage_custom_string_key(
197       config.tokens_cache, urlhost.rstrip('/'))
198
199
200 def _fetch_auth_service_url(urlhost):
201   """Fetches URL of a main authentication service used by |urlhost|.
202
203   Returns:
204     * If |urlhost| is using a authentication service, returns its URL.
205     * If |urlhost| is not using authentication servier, returns |urlhost|.
206     * If there was a error communicating with |urlhost|, returns None.
207   """
208   # TODO(vadimsh): Cache {urlhost -> primary_url} mapping locally on disk
209   # to avoid round trip to the server all the time.
210   service_config = _fetch_service_config(urlhost)
211   if not service_config:
212     return None
213   url = (service_config.primary_url or urlhost).rstrip('/')
214   assert url.startswith(('https://', 'http://localhost:')), url
215   return url
216
217
218 def _fetch_service_config(urlhost):
219   """Fetches OAuth related configuration from a service.
220
221   The configuration includes OAuth client_id and client_secret, as well as
222   URL of a primary authentication service (or None if not used).
223
224   Returns:
225     Instance of _ServiceConfig on success, None on failure.
226   """
227   def do_fetch():
228     # client_secret is not really a secret in that case. So an attacker can
229     # impersonate service's identity in OAuth2 flow. But that's generally
230     # fine as long as a list of allowed redirect_uri's associated with client_id
231     # is limited to 'localhost' or 'urn:ietf:wg:oauth:2.0:oob'. In that case
232     # attacker needs some process running on user's machine to successfully
233     # complete the flow and grab access_token. When you have malicious code
234     # running on your machine you're screwed anyway.
235     response = requests.get(
236         '%s/auth/api/v1/server/oauth_config' % urlhost.rstrip('/'),
237         verify=tools.get_cacerts_bundle())
238     if response.status_code == 200:
239       try:
240         config = response.json()
241         if not isinstance(config, dict):
242           raise ValueError()
243         return _ServiceConfig(
244             config['client_id'],
245             config['client_not_so_secret'],
246             config.get('primary_url'))
247       except (KeyError, ValueError) as err:
248         logging.error('Invalid response from the service: %s', err)
249     else:
250       logging.error(
251           'Error when fetching oauth_config, HTTP status code %d',
252           response.status_code)
253     return None
254
255   # Use local cache to avoid unnecessary network calls.
256   with _service_config_cache_lock:
257     if urlhost not in _service_config_cache:
258       config = do_fetch()
259       if config:
260         _service_config_cache[urlhost] = config
261     return _service_config_cache.get(urlhost)
262
263
264 # The chunk of code below is based on oauth2client.tools module. Unfortunately
265 # 'tools' module itself depends on 'argparse' module unavailable on Python 2.6
266 # so it can't be imported directly.
267
268
269 def _run_oauth_dance(urlhost, storage, config):
270   """Perform full OAuth2 dance with the browser."""
271   # Fetch client_id and client_secret from the service itself.
272   service_config = _fetch_service_config(urlhost)
273   if not service_config:
274     print 'Couldn\'t fetch OAuth configuration'
275     return None
276   if not service_config.client_id or not service_config.client_secret:
277     print 'OAuth is not configured on the service'
278     return None
279
280   # Appengine expects a token scoped to 'userinfo.email'.
281   flow = client.OAuth2WebServerFlow(
282       service_config.client_id,
283       service_config.client_secret,
284       'https://www.googleapis.com/auth/userinfo.email',
285       approval_prompt='force')
286
287   use_local_webserver = not config.no_local_webserver
288   port = config.webserver_port
289   if use_local_webserver:
290     success = False
291     try:
292       httpd = ClientRedirectServer(('localhost', port), ClientRedirectHandler)
293     except socket.error:
294       pass
295     else:
296       success = True
297     use_local_webserver = success
298     if not success:
299       print 'Failed to start a local webserver listening on port %d.' % port
300       print 'Please check your firewall settings and locally'
301       print 'running programs that may be blocking or using those ports.'
302       print
303       print 'Falling back to --auth-no-local-webserver and continuing with',
304       print 'authorization.'
305       print
306
307   if use_local_webserver:
308     oauth_callback = 'http://localhost:%s/' % port
309   else:
310     oauth_callback = client.OOB_CALLBACK_URN
311   flow.redirect_uri = oauth_callback
312   authorize_url = flow.step1_get_authorize_url()
313
314   if use_local_webserver:
315     webbrowser.open(authorize_url, new=1, autoraise=True)
316     print 'Your browser has been opened to visit:'
317     print
318     print '    ' + authorize_url
319     print
320     print 'If your browser is on a different machine then exit and re-run this'
321     print 'application with the command-line parameter '
322     print
323     print '  --auth-no-local-webserver'
324     print
325   else:
326     print 'Go to the following link in your browser:'
327     print
328     print '    ' + authorize_url
329     print
330
331   code = None
332   if use_local_webserver:
333     httpd.handle_request()
334     if 'error' in httpd.query_params:
335       print 'Authentication request was rejected.'
336       return None
337     if 'code' not in httpd.query_params:
338       print 'Failed to find "code" in the query parameters of the redirect.'
339       print 'Try running with --auth-no-local-webserver.'
340       return None
341     code = httpd.query_params['code']
342   else:
343     code = raw_input('Enter verification code: ').strip()
344
345   try:
346     credential = flow.step2_exchange(code)
347   except client.FlowExchangeError as e:
348     print 'Authentication has failed: %s' % e
349     return None
350
351   print 'Authentication successful.'
352   storage.put(credential)
353   credential.set_store(storage)
354   return credential.access_token
355
356
357 class ClientRedirectServer(BaseHTTPServer.HTTPServer):
358   """A server to handle OAuth 2.0 redirects back to localhost.
359
360   Waits for a single request and parses the query parameters
361   into query_params and then stops serving.
362   """
363   query_params = {}
364
365
366 class ClientRedirectHandler(BaseHTTPServer.BaseHTTPRequestHandler):
367   """A handler for OAuth 2.0 redirects back to localhost.
368
369   Waits for a single request and parses the query parameters
370   into the servers query_params and then stops serving.
371   """
372
373   def do_GET(self):
374     """Handle a GET request.
375
376     Parses the query parameters and prints a message
377     if the flow has completed. Note that we can't detect
378     if an error occurred.
379     """
380     self.send_response(200)
381     self.send_header('Content-type', 'text/html')
382     self.end_headers()
383     query = self.path.split('?', 1)[-1]
384     query = dict(urlparse.parse_qsl(query))
385     self.server.query_params = query
386     self.wfile.write('<html><head><title>Authentication Status</title></head>')
387     self.wfile.write('<body><p>The authentication flow has completed.</p>')
388     self.wfile.write('</body></html>')
389
390   def log_message(self, _format, *args):
391     """Do not log messages to stdout while running as command line program."""