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.
5 """OAuth2 related utilities and implementation of browser based login flow."""
7 # pylint: disable=W0613
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'))
27 from oauth2client import client
28 from oauth2client import multistore_file
30 from third_party import requests
31 from utils import tools
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')
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', [
49 # Configuration fetched from a service, returned by _fetch_service_config.
50 _ServiceConfig = collections.namedtuple('_ServiceConfig', [
56 # Process cache of _fetch_service_config results.
57 _service_config_cache = {}
58 _service_config_cache_lock = threading.Lock()
61 def make_oauth_config(
62 tokens_cache=None, no_local_webserver=None, webserver_port=None):
63 """Returns new instance of OAuthConfig.
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.
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.
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:
84 return OAuthConfig(tokens_cache, no_local_webserver, webserver_port)
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',
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(
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)
116 def extract_oauth_config_from_options(options):
117 """Given OptionParser with oauth options, extracts OAuthConfig from it.
119 OptionParser should be populated with oauth options by 'add_oauth_options'.
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)
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:
133 storage = _get_storage(auth_service_url, config)
134 credentials = storage.get()
136 if not credentials or credentials.invalid:
139 if not credentials.access_token or credentials.access_token_expired:
141 return credentials.access_token
144 def create_access_token(urlhost, config, allow_user_interaction):
145 """Mints and caches new access_token, launching OAuth2 dance if necessary.
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).
154 access_token on success.
155 None on error or if OAuth2 flow was interrupted.
157 assert isinstance(config, OAuthConfig)
158 auth_service_url = _fetch_auth_service_url(urlhost)
159 if not auth_service_url:
161 storage = _get_storage(auth_service_url, config)
162 credentials = storage.get()
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)
170 # refresh_token is ok, use it.
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)
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
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)
191 _get_storage(auth_service_url, config).delete()
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('/'))
200 def _fetch_auth_service_url(urlhost):
201 """Fetches URL of a main authentication service used by |urlhost|.
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.
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:
213 url = (service_config.primary_url or urlhost).rstrip('/')
214 assert url.startswith(('https://', 'http://localhost:')), url
218 def _fetch_service_config(urlhost):
219 """Fetches OAuth related configuration from a service.
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).
225 Instance of _ServiceConfig on success, None on failure.
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:
240 config = response.json()
241 if not isinstance(config, dict):
243 return _ServiceConfig(
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)
251 'Error when fetching oauth_config, HTTP status code %d',
252 response.status_code)
255 # Use local cache to avoid unnecessary network calls.
256 with _service_config_cache_lock:
257 if urlhost not in _service_config_cache:
260 _service_config_cache[urlhost] = config
261 return _service_config_cache.get(urlhost)
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.
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'
276 if not service_config.client_id or not service_config.client_secret:
277 print 'OAuth is not configured on the service'
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')
287 use_local_webserver = not config.no_local_webserver
288 port = config.webserver_port
289 if use_local_webserver:
292 httpd = ClientRedirectServer(('localhost', port), ClientRedirectHandler)
297 use_local_webserver = 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.'
303 print 'Falling back to --auth-no-local-webserver and continuing with',
304 print 'authorization.'
307 if use_local_webserver:
308 oauth_callback = 'http://localhost:%s/' % port
310 oauth_callback = client.OOB_CALLBACK_URN
311 flow.redirect_uri = oauth_callback
312 authorize_url = flow.step1_get_authorize_url()
314 if use_local_webserver:
315 webbrowser.open(authorize_url, new=1, autoraise=True)
316 print 'Your browser has been opened to visit:'
318 print ' ' + authorize_url
320 print 'If your browser is on a different machine then exit and re-run this'
321 print 'application with the command-line parameter '
323 print ' --auth-no-local-webserver'
326 print 'Go to the following link in your browser:'
328 print ' ' + authorize_url
332 if use_local_webserver:
333 httpd.handle_request()
334 if 'error' in httpd.query_params:
335 print 'Authentication request was rejected.'
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.'
341 code = httpd.query_params['code']
343 code = raw_input('Enter verification code: ').strip()
346 credential = flow.step2_exchange(code)
347 except client.FlowExchangeError as e:
348 print 'Authentication has failed: %s' % e
351 print 'Authentication successful.'
352 storage.put(credential)
353 credential.set_store(storage)
354 return credential.access_token
357 class ClientRedirectServer(BaseHTTPServer.HTTPServer):
358 """A server to handle OAuth 2.0 redirects back to localhost.
360 Waits for a single request and parses the query parameters
361 into query_params and then stops serving.
366 class ClientRedirectHandler(BaseHTTPServer.BaseHTTPRequestHandler):
367 """A handler for OAuth 2.0 redirects back to localhost.
369 Waits for a single request and parses the query parameters
370 into the servers query_params and then stops serving.
374 """Handle a GET request.
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.
380 self.send_response(200)
381 self.send_header('Content-type', 'text/html')
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>')
390 def log_message(self, _format, *args):
391 """Do not log messages to stdout while running as command line program."""