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
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'))
26 from oauth2client import client
27 from oauth2client import multistore_file
29 from third_party import requests
30 from utils import tools
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')
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', [
48 def make_oauth_config(
49 tokens_cache=None, no_local_webserver=None, webserver_port=None):
50 """Returns new instance of OAuthConfig.
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.
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.
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:
71 return OAuthConfig(tokens_cache, no_local_webserver, webserver_port)
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',
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(
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)
103 def extract_oauth_config_from_options(options):
104 """Given OptionParser with oauth options, extracts OAuthConfig from it.
106 OptionParser should be populated with oauth options by 'add_oauth_options'.
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)
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()
120 if not credentials or credentials.invalid:
123 if not credentials.access_token or credentials.access_token_expired:
125 return credentials.access_token
128 def create_access_token(urlhost, config, allow_user_interaction):
129 """Mints and caches new access_token, launching OAuth2 dance if necessary.
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).
138 access_token on success.
139 None on error or if OAuth2 flow was interrupted.
141 assert isinstance(config, OAuthConfig)
142 storage = _get_storage(urlhost, config)
143 credentials = storage.get()
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)
151 # refresh_token is ok, use it.
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)
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
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()
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('/'))
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:
192 config = response.json()
193 if not isinstance(config, dict):
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)
200 'Error when fetching oauth_config, HTTP status code %d',
201 response.status_code)
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.
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'
218 # Appengine expects a token scoped to 'userinfo.email'.
219 flow = client.OAuth2WebServerFlow(
221 client_not_so_secret,
222 'https://www.googleapis.com/auth/userinfo.email',
223 approval_prompt='force')
225 use_local_webserver = not config.no_local_webserver
226 port = config.webserver_port
227 if use_local_webserver:
230 httpd = ClientRedirectServer(('localhost', port), ClientRedirectHandler)
235 use_local_webserver = 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.'
241 print 'Falling back to --auth-no-local-webserver and continuing with',
242 print 'authorization.'
245 if use_local_webserver:
246 oauth_callback = 'http://localhost:%s/' % port
248 oauth_callback = client.OOB_CALLBACK_URN
249 flow.redirect_uri = oauth_callback
250 authorize_url = flow.step1_get_authorize_url()
252 if use_local_webserver:
253 webbrowser.open(authorize_url, new=1, autoraise=True)
254 print 'Your browser has been opened to visit:'
256 print ' ' + authorize_url
258 print 'If your browser is on a different machine then exit and re-run this'
259 print 'application with the command-line parameter '
261 print ' --auth-no-local-webserver'
264 print 'Go to the following link in your browser:'
266 print ' ' + authorize_url
270 if use_local_webserver:
271 httpd.handle_request()
272 if 'error' in httpd.query_params:
273 print 'Authentication request was rejected.'
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.'
279 code = httpd.query_params['code']
281 code = raw_input('Enter verification code: ').strip()
284 credential = flow.step2_exchange(code)
285 except client.FlowExchangeError as e:
286 print 'Authentication has failed: %s' % e
289 print 'Authentication successful.'
290 storage.put(credential)
291 credential.set_store(storage)
292 return credential.access_token
295 class ClientRedirectServer(BaseHTTPServer.HTTPServer):
296 """A server to handle OAuth 2.0 redirects back to localhost.
298 Waits for a single request and parses the query parameters
299 into query_params and then stops serving.
304 class ClientRedirectHandler(BaseHTTPServer.BaseHTTPRequestHandler):
305 """A handler for OAuth 2.0 redirects back to localhost.
307 Waits for a single request and parses the query parameters
308 into the servers query_params and then stops serving.
312 """Handle a GET request.
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.
318 self.send_response(200)
319 self.send_header('Content-type', 'text/html')
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>')
328 def log_message(self, _format, *args):
329 """Do not log messages to stdout while running as command line program."""