blob: 485adbcd5697d24993a6d4fbe5861459dcd5cce7 [file] [log] [blame]
#!/usr/bin/env python
# Copyright 2015 The Goma Authors. All rights reserved.
# Use of this source code is governed by a BSD-style license that can be
# found in the LICENSE file.
"""A Script to set goma_oauth2_config."""
import argparse
import BaseHTTPServer
import copy
import json
import os
import string
import subprocess
import sys
import urllib
import urlparse
import webbrowser
import random
SCRIPT_DIR = os.path.dirname(os.path.realpath(__file__))
GOOGLE_AUTH_URI = 'https://accounts.google.com/o/oauth2/auth'
OAUTH_SCOPES = 'https://www.googleapis.com/auth/userinfo.email'
OAUTH_TOKEN_ENDPOINT = 'https://www.googleapis.com/oauth2/v3/token'
TOKEN_INFO_ENDPOINT = 'https://www.googleapis.com/oauth2/v3/tokeninfo'
OOB_CALLBACK_URN = 'urn:ietf:wg:oauth:2.0:oob'
DEFAULT_GOMA_OAUTH2_CONFIG_FILE_NAME = '.goma_oauth2_config'
OAUTH_STATE_LENGTH = 64
if os.name == 'nt':
GOMA_FETCH = os.path.join(SCRIPT_DIR, 'goma_fetch.exe')
else:
GOMA_FETCH = os.path.join(SCRIPT_DIR, 'goma_fetch')
def ConfirmUserAgreedToS():
"""Returns if user agreed on ToS."""
print """You can use Goma (distributed compiler service) if you agree on the
way we use data and the way we share data.
1. How do we use data?
a. the data used for compiling
- source code (including contents and file paths) to be compiled.
- header files (including contents and file paths) used during the compile.
- other files accessed by compilers. e.g. asan_blacklist.txt, crtbegin.o,
profiling data for pgo.
- identifier of a compiler to use. (SHA256 hash value of a compiler, version,
target).
- command line arguments and environment variables necessary for a compile,
system include paths, current working directory.
b. the data used for authentication
- OAuth2 access token to use service, and email address gotten from access
token.
Google may use data for logging and tracking (including abuse detection).
Google keeps identifier of each compile (goma client start time, goma client
id that changes when compiler_proxy starts, sequential compile id)
2. What data will be shared?
Contents in source code and header files are shared among users who send
SHA256 hash values of them. Compile results are shared among users who have
sent the requests that bring the same compile result.
"""
yn = raw_input('Do you agree to our data usage policy? (y/n) -->')
if yn in ('Y', 'y'):
print 'You have agreed.'
return
sys.exit(1)
class Error(Exception):
"""Raised on Error."""
class GomaOAuth2Config(dict):
"""File-backed OAuth2 configuration."""
def __init__(self):
dict.__init__(self)
self._path = self._GetLocation()
@staticmethod
def _GetLocation():
"""Returns Goma OAuth2 config file path."""
env_name = 'GOMA_OAUTH2_CONFIG_FILE'
env = os.environ.get(env_name)
if env:
return env
homedir = os.path.expanduser('~')
if homedir == '~':
raise Error('Cannot find user\'s home directory.')
return os.path.join(homedir, DEFAULT_GOMA_OAUTH2_CONFIG_FILE_NAME)
def Load(self):
"""Loads config from a file."""
if not os.path.exists(self._path):
return False
try:
with open(self._path) as f:
self.update(json.load(f))
except ValueError:
return False
if not self.get('refresh_token'):
return False
return True
def Save(self):
"""Saves config to a file."""
# TODO: not save unnecessary data.
with open(self._path, 'wb') as f:
if os.name == 'posix':
os.fchmod(f.fileno(), 0600)
json.dump(self, f)
def Delete(self):
"""Deletes a config file."""
if not os.path.exists(self._path):
return
os.remove(self._path)
def HttpGetRequest(url):
"""Proceed an HTTP GET request, and returns an HTTP response body.
Args:
url: a URL string of an HTTP server.
Returns:
a response from the server.
"""
cmd = [GOMA_FETCH, '--no-auth', url]
return subprocess.check_output(cmd)
def HttpPostRequest(url, post_dict):
"""Proceed an HTTP POST request, and returns an HTTP response body.
Args:
url: a URL string of an HTTP server.
post_dict: a dictionary of a body to be posted.
Returns:
a response from the server.
"""
body = urllib.urlencode(post_dict)
cmd = [GOMA_FETCH, '--no-auth', '--post', url, '--data', body]
return subprocess.check_output(cmd)
def DefaultOAuth2Config():
"""Returns default OAuth2 config.
same as oauth2.cc:DefaultOAuth2Config.
TODO: run compiler_propxy to generate default oauth2 config?
Returns:
a dictionary of OAuth2 config.
"""
return {
'client_id': ('687418631491-r6m1c3pr0lth5atp4ie07f03ae8omefc.'
'apps.googleusercontent.com'),
'client_secret': 'R7e-JO3L5sKVczuR-dKQrijF',
'redirect_uri': OOB_CALLBACK_URN,
'auth_uri': GOOGLE_AUTH_URI,
'scope': OAUTH_SCOPES,
'token_uri': OAUTH_TOKEN_ENDPOINT,
}
class AuthorizationCodeHandler(BaseHTTPServer.BaseHTTPRequestHandler):
"""HTTP handler to get authorization code."""
code = None
state = None
@classmethod
def _SetCode(cls, code):
"""Internal function to set code to class variable."""
if not code:
raise Error('code is None')
cls.code = code[0]
def do_GET(self):
"""A handler to receive authorization code."""
if self.address_string() != 'localhost':
raise Error('should be localhost but %s' % self.client_address)
form = urlparse.parse_qs(urlparse.urlparse(self.path).query)
server_state = form.get('state', [''])[0]
if server_state != self.state:
raise Error('possibly XSRF: state from server (%s) is not %s' % (
server_state, self.state))
self._SetCode(form.get('code'))
self.send_response(200, "OK")
def _RandomString(length):
"""Returns random string.
Args:
length: length of the string.
Returns:
random string.
"""
generator = random.SystemRandom()
return ''.join(generator.choice(string.letters + string.digits)
for _ in xrange(length))
def GetAuthorizationCodeViaBrowser(config):
"""Gets authorization code using browser.
This way is useful for users with desktop machines.
Args:
config: a dictionary of config.
Returns:
authorization code.
"""
AuthorizationCodeHandler.state = _RandomString(OAUTH_STATE_LENGTH)
httpd = BaseHTTPServer.HTTPServer(('', 0), AuthorizationCodeHandler)
config['redirect_uri'] = 'http://localhost:%d' % httpd.server_port
body = urllib.urlencode({
'scope': config['scope'],
'redirect_uri': config['redirect_uri'],
'client_id': config['client_id'],
'state': AuthorizationCodeHandler.state,
'response_type': 'code'})
google_auth_url = '%s?%s' % (config['auth_uri'], body)
webbrowser.open(google_auth_url)
httpd.handle_request()
httpd.server_close()
return AuthorizationCodeHandler.code
def GetAuthorizationCodeViaCommandLine(config):
"""Gets authorization code via command line.
This way is useful anywhere without a browser.
Args:
config: a dictionary of config.
Returns:
authorization code.
"""
body = urllib.urlencode({
'scope': config['scope'],
'redirect_uri': config['redirect_uri'],
'client_id': config['client_id'],
'response_type': 'code'})
google_auth_url = '%s?%s' % (config['auth_uri'], body)
print 'Please visit following URL with your browser, and approve access:'
print google_auth_url
return raw_input('Please input the code:')
def GetRefreshToken(get_code_func, config):
"""Get refresh token with oauth 3 legged authentication.
Args:
get_code_func: a function for getting authorization code.
config: a dictionary of config.
Returns:
a refresh token string.
"""
code = get_code_func(config)
assert code and isinstance(code, str)
post_data = {
'code': code,
'client_id': config['client_id'],
'client_secret': config['client_secret'],
'redirect_uri': config['redirect_uri'],
'grant_type': 'authorization_code'
}
resp = json.loads(HttpPostRequest(config['token_uri'], post_data))
return resp['refresh_token']
def VerifyRefreshToken(config):
"""Verify refresh token in config.
Returns:
'' if a refresh token in config is valid.
error message if something wrong.
"""
if not 'refresh_token' in config:
return 'no refresh token in config'
post_data = {
'client_id': config['client_id'],
'client_secret': config['client_secret'],
'refresh_token': config['refresh_token'],
'grant_type': 'refresh_token'
}
resp = json.loads(HttpPostRequest(config['token_uri'], post_data))
if 'error' in resp:
return 'obtain access token: %s' % resp['error']
token_info = json.loads(HttpPostRequest(
TOKEN_INFO_ENDPOINT,
{'access_token': resp['access_token']}))
if 'error_description' in token_info:
return 'token info: %s' % token_info['error_description']
if not 'email' in token_info:
return 'no email in token_info %s' % token_info
print 'Login as ' + token_info['email']
return ''
def Login():
"""Performs interactive login and caches authentication token.
Returns:
non-zero value on error.
"""
ConfirmUserAgreedToS()
parser = argparse.ArgumentParser()
parser.add_argument('--browser', action='store_true',
help=('Use browser to get goma OAuth2 token.'))
options = parser.parse_args(sys.argv[2:])
config = GomaOAuth2Config()
config.update(DefaultOAuth2Config())
func = GetAuthorizationCodeViaCommandLine
if options.browser:
func = GetAuthorizationCodeViaBrowser
config['refresh_token'] = GetRefreshToken(func, config)
err = VerifyRefreshToken(config)
if err:
sys.stderr.write(err + '\n')
return 1
config.Save()
return 0
def Logout():
"""Removes a cached authentication token.
Returns:
non-zero value on error.
"""
config = GomaOAuth2Config()
config.Delete()
return 0
def Info():
"""Shows email associated with a cached authentication token.
Returns:
non-zero value on error.
"""
config = GomaOAuth2Config()
if not config.Load():
sys.stderr.write('Not logged in\n')
return 1
err = VerifyRefreshToken(config)
if err:
sys.stderr.write(err + '\n')
return 1
return 0
def Help():
"""Print Usage"""
print '''Usage: %(cmd)s <command> [options]
Commands are:
login performs interactive login and caches authentication token
logout revokes cached authentication token
info shows email associated with a cached authentication token
Options are:
--browser use browser to get goma OAuth2 token (login command only)
''' % {'cmd' : sys.argv[0]}
return 0
def main():
action_mapping = {
'login': Login,
'logout': Logout,
'info': Info,
}
action = Help
if len(sys.argv) > 1:
action = action_mapping.get(sys.argv[1], Help)
return action()
if __name__ == '__main__':
sys.exit(main())