Use app_id with github

The basics of authenticating to github as an app when posting
comments and cloning. This is still a WIP.

Change-Id: I11fab75d635a8bcea7210945df4071bf51d7d3f2
diff --git a/requirements.txt b/requirements.txt
index 3bb7f91..48ac38e 100644
--- a/requirements.txt
+++ b/requirements.txt
@@ -24,3 +24,5 @@
 alembic
 cryptography>=1.6
 cachecontrol
+pyjwt
+iso8601
diff --git a/zuul/driver/github/githubconnection.py b/zuul/driver/github/githubconnection.py
index a17ec40..a09e46f 100644
--- a/zuul/driver/github/githubconnection.py
+++ b/zuul/driver/github/githubconnection.py
@@ -13,6 +13,7 @@
 # under the License.
 
 import collections
+import datetime
 import logging
 import hmac
 import hashlib
@@ -20,6 +21,9 @@
 
 import cachecontrol
 from cachecontrol.cache import DictCache
+import iso8601
+import jwt
+import requests
 import webob
 import webob.dec
 import voluptuous as v
@@ -31,6 +35,25 @@
 from zuul.exceptions import MergeFailure
 from zuul.driver.github.githubmodel import PullRequest, GithubTriggerEvent
 
+ACCESS_TOKEN_URL = 'https://api.github.com/installations/%s/access_tokens'
+PREVIEW_JSON_ACCEPT = 'application/vnd.github.machine-man-preview+json'
+
+
+class UTC(datetime.tzinfo):
+    """UTC"""
+
+    def utcoffset(self, dt):
+        return datetime.timedelta(0)
+
+    def tzname(self, dt):
+        return "UTC"
+
+    def dst(self, dt):
+        return datetime.timedelta(0)
+
+
+utc = UTC()
+
 
 class GithubWebhookListener():
 
@@ -279,20 +302,25 @@
     driver_name = 'github'
     log = logging.getLogger("connection.github")
     payload_path = 'payload'
-    git_user = 'git'
 
     def __init__(self, driver, connection_name, connection_config):
         super(GithubConnection, self).__init__(
             driver, connection_name, connection_config)
-        self.github = None
         self._change_cache = {}
         self.projects = {}
-        self._git_ssh = bool(self.connection_config.get('sshkey', None))
+        self.git_ssh_key = self.connection_config.get('sshkey')
         self.git_host = self.connection_config.get('git_host', 'github.com')
         self.canonical_hostname = self.connection_config.get(
             'canonical_hostname', self.git_host)
         self.source = driver.getSource(self)
 
+        self._github = None
+        self.app_id = None
+        self.app_key = None
+        self.installation_id = None
+        self.installation_token = None
+        self.installation_expiry = None
+
         # NOTE(jamielennox): Better here would be to cache to memcache or file
         # or something external - but zuul already sucks at restarting so in
         # memory probably doesn't make this much worse.
@@ -310,23 +338,86 @@
         self.unregisterHttpHandler(self.payload_path)
 
     def _authenticateGithubAPI(self):
-        token = self.connection_config.get('api_token', None)
-        if token is not None:
-            if self.git_host != 'github.com':
-                url = 'https://%s/' % self.git_host
-                self.github = github3.enterprise_login(token=token, url=url)
-            else:
-                self.github = github3.login(token=token)
-            self.log.info("Github API Authentication successful.")
+        config = self.connection_config
 
-            # anything going through requests to http/s goes through cache
-            self.github.session.mount('http://', self.cache_adapter)
-            self.github.session.mount('https://', self.cache_adapter)
+        if self.git_host != 'github.com':
+            url = 'https://%s/' % self.git_host
+            github = github3.GitHubEnterprise(url)
         else:
-            self.github = None
-            self.log.info(
-                "No Github credentials found in zuul configuration, cannot "
-                "authenticate.")
+            github = github3.GitHub()
+
+        # anything going through requests to http/s goes through cache
+        github.session.mount('http://', self.cache_adapter)
+        github.session.mount('https://', self.cache_adapter)
+
+        api_token = config.get('api_token')
+
+        if api_token:
+            github.login(token=api_token)
+        else:
+            app_id = config.get('app_id')
+            installation_id = config.get('installation_id')
+            app_key_file = config.get('app_key')
+
+            if app_key_file:
+                with open(app_key_file, 'r') as f:
+                    app_key = f.read()
+
+            if not (app_id and app_key and installation_id):
+                self.log.warning("You must provide an app_id, "
+                                 "app_key and installation_id to use "
+                                 "installation based authentication")
+
+                return
+
+            self.app_id = int(app_id)
+            self.installation_id = int(installation_id)
+            self.app_key = app_key
+
+        self._github = github
+
+    def _get_installation_key(self, user_id=None):
+        if not (self.installation_id and self.app_id):
+            return None
+
+        now = datetime.datetime.now(utc)
+
+        if ((not self.installation_expiry) or
+                (not self.installation_token) or
+                (now < self.installation_expiry)):
+            expiry = now + datetime.timedelta(minutes=5)
+
+            data = {'iat': now, 'exp': expiry, 'iss': self.app_id}
+            app_token = jwt.encode(data,
+                                   self.app_key,
+                                   algorithm='RS256')
+
+            url = ACCESS_TOKEN_URL % self.installation_id
+            headers = {'Accept': PREVIEW_JSON_ACCEPT,
+                       'Authorization': 'Bearer %s' % app_token}
+            json_data = {'user_id': user_id} if user_id else None
+
+            response = requests.post(url, headers=headers, json=json_data)
+            response.raise_for_status()
+
+            data = response.json()
+
+            self.installation_expiry = iso8601.parse_date(data['expires_at'])
+            self.installation_expiry -= datetime.timedelta(minutes=5)
+            self.installation_token = data['token']
+
+        return self.installation_token
+
+    @property
+    def github(self):
+        # if we're using api_key authentication then we don't need to fetch
+        # new installation tokens so return the existing one.
+        installation_key = self._get_installation_key()
+
+        if installation_key:
+            self._github.login(token=installation_key)
+
+        return self._github
 
     def maintainCache(self, relevant):
         for key, change in self._change_cache.items():
@@ -363,12 +454,16 @@
         return change
 
     def getGitUrl(self, project):
-        if self._git_ssh:
-            url = 'ssh://%s@%s/%s.git' % \
-                (self.git_user, self.git_host, project)
-        else:
-            url = 'https://%s/%s' % (self.git_host, project)
-        return url
+        if self.git_ssh_key:
+            return 'ssh://git@%s/%s.git' % (self.git_host, project)
+
+        installation_key = self._get_installation_key()
+        if installation_key:
+            return 'https://x-access-token:%s@%s/%s' % (installation_key,
+                                                        self.git_host,
+                                                        project)
+
+        return 'https://%s/%s' % (self.git_host, project)
 
     def getGitwebUrl(self, project, sha=None):
         url = 'https://%s/%s' % (self.git_host, project)