Support full GitHub app authentication (#1986)

* Support full GitHub app authentication
 Refactor GithubIntegration class and add test case for app authentication
Add permissions and repository properties in InstallationAuthorization
Set JWT_EXPIRY=60 by default in GithubIntegration constructor
* Modify existing testcases for GithubIntegration as per the framework and add missing tests
* Provide installation ID for creating the access token instead of getting the first installation
* Add optional permissions support for installation access token
* Add lock around app authentication
* Keep compatibility for importing GithubIntegration from MainClass
* Group app authentication parameters in a class

Co-authored-by: Malik Ammar Akbar <malikammar.akbar@pfizer.com>
Co-authored-by: Enrico Minack <github@enrico.minack.dev>
This commit is contained in:
Denis Blanchette
2023-02-06 20:50:15 +11:00
committed by GitHub
co-authored by Malik Ammar Akbar Enrico Minack
parent 7cf3dfc18e
commit 5e27c10a31
30 changed files with 817 additions and 304 deletions
+14 -113
View File
@@ -49,10 +49,7 @@
import datetime
import pickle
import time
import jwt
import requests
import urllib3
import github.ApplicationOAuth
@@ -68,24 +65,13 @@ from . import (
AuthenticatedUser,
Consts,
GithubApp,
GithubException,
GitignoreTemplate,
HookDescription,
Installation,
InstallationAuthorization,
RateLimit,
Repository,
)
from .Requester import Requester
DEFAULT_BASE_URL = "https://api.github.com"
DEFAULT_STATUS_URL = "https://status.github.com"
# As of 2018-05-17, Github imposes a 10s limit for completion of API requests.
# Thus, the timeout should be slightly > 10s to account for network/front-end
# latency.
DEFAULT_TIMEOUT = 15
DEFAULT_PER_PAGE = 30
class Github:
"""
@@ -97,10 +83,11 @@ class Github:
login_or_token=None,
password=None,
jwt=None,
base_url=DEFAULT_BASE_URL,
timeout=DEFAULT_TIMEOUT,
app_auth=None,
base_url=Consts.DEFAULT_BASE_URL,
timeout=Consts.DEFAULT_TIMEOUT,
user_agent="PyGithub/Python",
per_page=DEFAULT_PER_PAGE,
per_page=Consts.DEFAULT_PER_PAGE,
verify=True,
retry=None,
pool_size=None,
@@ -108,6 +95,8 @@ class Github:
"""
:param login_or_token: string
:param password: string
:param jwt: string
:param app_auth: github.AppAuthentication
:param base_url: string
:param timeout: integer
:param user_agent: string
@@ -125,14 +114,16 @@ class Github:
assert user_agent is None or isinstance(user_agent, str), user_agent
assert (
retry is None
or isinstance(retry, (int))
or isinstance(retry, (urllib3.util.Retry))
)
assert pool_size is None or isinstance(pool_size, (int)), pool_size
or isinstance(retry, int)
or isinstance(retry, urllib3.util.Retry)
), retry
assert pool_size is None or isinstance(pool_size, int), pool_size
self.__requester = Requester(
login_or_token,
password,
jwt,
app_auth,
base_url,
timeout,
user_agent,
@@ -786,95 +777,5 @@ class Github:
return GithubApp.GithubApp(self.__requester, headers, data, completed=True)
class GithubIntegration:
"""
Main class to obtain tokens for a GitHub integration.
"""
def __init__(self, integration_id, private_key, base_url=DEFAULT_BASE_URL):
"""
:param base_url: string
:param integration_id: int
:param private_key: string
"""
self.base_url = base_url
self.integration_id = integration_id
self.private_key = private_key
assert isinstance(base_url, str), base_url
def create_jwt(self, expiration=60):
"""
Creates a signed JWT, valid for 60 seconds by default.
The expiration can be extended beyond this, to a maximum of 600 seconds.
:param expiration: int
:return string:
"""
now = int(time.time())
payload = {"iat": now, "exp": now + expiration, "iss": self.integration_id}
encrypted = jwt.encode(payload, key=self.private_key, algorithm="RS256")
if isinstance(encrypted, bytes):
encrypted = encrypted.decode("utf-8")
return encrypted
def get_access_token(self, installation_id, user_id=None):
"""
Get an access token for the given installation id.
POSTs https://api.github.com/app/installations/<installation_id>/access_tokens
:param user_id: int
:param installation_id: int
:return: :class:`github.InstallationAuthorization.InstallationAuthorization`
"""
body = {}
if user_id:
body = {"user_id": user_id}
response = requests.post(
f"{self.base_url}/app/installations/{installation_id}/access_tokens",
headers={
"Authorization": f"Bearer {self.create_jwt()}",
"Accept": Consts.mediaTypeIntegrationPreview,
"User-Agent": "PyGithub/Python",
},
json=body,
)
if response.status_code == 201:
return InstallationAuthorization.InstallationAuthorization(
requester=None, # not required, this is a NonCompletableGithubObject
headers={}, # not required, this is a NonCompletableGithubObject
attributes=response.json(),
completed=True,
)
elif response.status_code == 403:
raise GithubException.BadCredentialsException(
status=response.status_code, data=response.text
)
elif response.status_code == 404:
raise GithubException.UnknownObjectException(
status=response.status_code, data=response.text
)
raise GithubException.GithubException(
status=response.status_code, data=response.text
)
def get_installation(self, owner, repo):
"""
:calls: `GET /repos/{owner}/{repo}/installation <https://docs.github.com/en/rest/reference/apps#get-a-repository-installation-for-the-authenticated-app>`_
:param owner: str
:param repo: str
:rtype: :class:`github.Installation.Installation`
"""
headers = {
"Authorization": f"Bearer {self.create_jwt()}",
"Accept": Consts.mediaTypeIntegrationPreview,
"User-Agent": "PyGithub/Python",
}
response = requests.get(
f"{self.base_url}/repos/{owner}/{repo}/installation",
headers=headers,
)
response_dict = response.json()
return Installation.Installation(None, headers, response_dict, True)
# Retrocompatibility
GithubIntegration = github.GithubIntegration