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