/ github / Auth.py
Auth.py
  1  ############################ Copyrights and license ############################
  2  #                                                                              #
  3  # Copyright 2023 Enrico Minack <github@enrico.minack.dev>                      #
  4  #                                                                              #
  5  # This file is part of PyGithub.                                               #
  6  # http://pygithub.readthedocs.io/                                              #
  7  #                                                                              #
  8  # PyGithub is free software: you can redistribute it and/or modify it under    #
  9  # the terms of the GNU Lesser General Public License as published by the Free  #
 10  # Software Foundation, either version 3 of the License, or (at your option)    #
 11  # any later version.                                                           #
 12  #                                                                              #
 13  # PyGithub is distributed in the hope that it will be useful, but WITHOUT ANY  #
 14  # WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS    #
 15  # FOR A PARTICULAR PURPOSE. See the GNU Lesser General Public License for more #
 16  # details.                                                                     #
 17  #                                                                              #
 18  # You should have received a copy of the GNU Lesser General Public License     #
 19  # along with PyGithub. If not, see <http://www.gnu.org/licenses/>.             #
 20  #                                                                              #
 21  ################################################################################
 22  
 23  import abc
 24  import base64
 25  import time
 26  from abc import ABC
 27  from datetime import datetime, timedelta, timezone
 28  from typing import Dict, Optional, Union
 29  
 30  import jwt
 31  from requests import utils
 32  
 33  from github import Consts
 34  from github.InstallationAuthorization import InstallationAuthorization
 35  from github.Requester import Requester, WithRequester
 36  
 37  # For App authentication, time remaining before token expiration to request a new one
 38  ACCESS_TOKEN_REFRESH_THRESHOLD_SECONDS = 20
 39  TOKEN_REFRESH_THRESHOLD_TIMEDELTA = timedelta(seconds=ACCESS_TOKEN_REFRESH_THRESHOLD_SECONDS)
 40  
 41  
 42  class Auth(abc.ABC):
 43      """
 44      This class is the base class of all authentication methods for Requester.
 45      """
 46  
 47      @property
 48      @abc.abstractmethod
 49      def token_type(self) -> str:
 50          """
 51          The type of the auth token as used in the HTTP Authorization header, e.g. Bearer or Basic.
 52          :return: token type
 53          """
 54  
 55      @property
 56      @abc.abstractmethod
 57      def token(self) -> str:
 58          """
 59          The auth token as used in the HTTP Authorization header.
 60          :return: token
 61          """
 62  
 63  
 64  class HTTPBasicAuth(Auth, abc.ABC):
 65      @property
 66      @abc.abstractmethod
 67      def username(self) -> str:
 68          """The username."""
 69  
 70      @property
 71      @abc.abstractmethod
 72      def password(self) -> str:
 73          """The password"""
 74  
 75      @property
 76      def token_type(self) -> str:
 77          return "Basic"
 78  
 79      @property
 80      def token(self) -> str:
 81          return base64.b64encode(f"{self.username}:{self.password}".encode()).decode("utf-8").replace("\n", "")
 82  
 83  
 84  class Login(HTTPBasicAuth):
 85      """
 86      This class is used to authenticate Requester with login and password.
 87      """
 88  
 89      def __init__(self, login: str, password: str):
 90          assert isinstance(login, str)
 91          assert len(login) > 0
 92          assert isinstance(password, str)
 93          assert len(password) > 0
 94  
 95          self._login = login
 96          self._password = password
 97  
 98      @property
 99      def login(self) -> str:
100          return self._login
101  
102      @property
103      def username(self) -> str:
104          return self.login
105  
106      @property
107      def password(self) -> str:
108          return self._password
109  
110  
111  class Token(Auth):
112      """
113      This class is used to authenticate Requester with a single constant token.
114      """
115  
116      def __init__(self, token: str):
117          assert isinstance(token, str)
118          assert len(token) > 0
119          self._token = token
120  
121      @property
122      def token_type(self) -> str:
123          return "token"
124  
125      @property
126      def token(self) -> str:
127          return self._token
128  
129  
130  class JWT(Auth, ABC):
131      """
132      This class is the base class to authenticate with a JSON Web Token (JWT).
133      https://docs.github.com/en/apps/creating-github-apps/authenticating-with-a-github-app/generating-a-json-web-token-jwt-for-a-github-app
134      """
135  
136      @property
137      def token_type(self) -> str:
138          return "Bearer"
139  
140  
141  class AppAuth(JWT):
142      """
143      This class is used to authenticate Requester as a GitHub App.
144      https://docs.github.com/en/apps/creating-github-apps/authenticating-with-a-github-app/authenticating-as-a-github-app
145      """
146  
147      def __init__(
148          self,
149          app_id: Union[int, str],
150          private_key: str,
151          jwt_expiry: int = Consts.DEFAULT_JWT_EXPIRY,
152          jwt_issued_at: int = Consts.DEFAULT_JWT_ISSUED_AT,
153          jwt_algorithm: str = Consts.DEFAULT_JWT_ALGORITHM,
154      ):
155          assert isinstance(app_id, (int, str)), app_id
156          if isinstance(app_id, str):
157              assert len(app_id) > 0, "app_id must not be empty"
158          assert isinstance(private_key, str)
159          assert len(private_key) > 0, "private_key must not be empty"
160          assert isinstance(jwt_expiry, int), jwt_expiry
161          assert Consts.MIN_JWT_EXPIRY <= jwt_expiry <= Consts.MAX_JWT_EXPIRY, jwt_expiry
162  
163          self._app_id = app_id
164          self._private_key = private_key
165          self._jwt_expiry = jwt_expiry
166          self._jwt_issued_at = jwt_issued_at
167          self._jwt_algorithm = jwt_algorithm
168  
169      @property
170      def app_id(self) -> Union[int, str]:
171          return self._app_id
172  
173      @property
174      def private_key(self) -> str:
175          return self._private_key
176  
177      @property
178      def token(self) -> str:
179          return self.create_jwt()
180  
181      def get_installation_auth(
182          self,
183          installation_id: int,
184          token_permissions: Optional[Dict[str, str]] = None,
185          requester: Optional[Requester] = None,
186      ) -> "AppInstallationAuth":
187          """
188          Creates a github.Auth.AppInstallationAuth instance for an installation.
189          :param installation_id: installation id
190          :param token_permissions: optional permissions
191          :param requester: optional requester with app authentication
192          :return:
193          """
194          return AppInstallationAuth(self, installation_id, token_permissions, requester)
195  
196      def create_jwt(self, expiration: Optional[int] = None) -> str:
197          """
198          Create a signed JWT
199          https://docs.github.com/en/developers/apps/building-github-apps/authenticating-with-github-apps#authenticating-as-a-github-app
200  
201          :return string: jwt
202          """
203          if expiration is not None:
204              assert isinstance(expiration, int), expiration
205              assert Consts.MIN_JWT_EXPIRY <= expiration <= Consts.MAX_JWT_EXPIRY, expiration
206  
207          now = int(time.time())
208          payload = {
209              "iat": now + self._jwt_issued_at,
210              "exp": now + (expiration if expiration is not None else self._jwt_expiry),
211              "iss": self._app_id,
212          }
213          encrypted = jwt.encode(payload, key=self.private_key, algorithm=self._jwt_algorithm)
214  
215          if isinstance(encrypted, bytes):
216              return encrypted.decode("utf-8")
217          return encrypted
218  
219  
220  class AppAuthToken(JWT):
221      """
222      This class is used to authenticate Requester as a GitHub App with a single constant JWT.
223      https://docs.github.com/en/apps/creating-github-apps/authenticating-with-a-github-app/authenticating-as-a-github-app
224      """
225  
226      def __init__(self, token: str):
227          assert isinstance(token, str)
228          assert len(token) > 0
229          self._token = token
230  
231      @property
232      def token(self) -> str:
233          return self._token
234  
235  
236  class AppInstallationAuth(Auth, WithRequester["AppInstallationAuth"]):
237      """
238      This class is used to authenticate Requester as a GitHub App Installation.
239      https://docs.github.com/en/apps/creating-github-apps/authenticating-with-a-github-app/authenticating-as-a-github-app-installation
240      """
241  
242      # imported here to avoid circular import, needed for typing only
243      from github.GithubIntegration import GithubIntegration
244  
245      # used to fetch live access token when calling self.token
246      __integration: Optional[GithubIntegration] = None
247      __installation_authorization: Optional[InstallationAuthorization] = None
248  
249      def __init__(
250          self,
251          app_auth: AppAuth,
252          installation_id: int,
253          token_permissions: Optional[Dict[str, str]] = None,
254          requester: Optional[Requester] = None,
255      ):
256          super().__init__()
257  
258          assert isinstance(app_auth, AppAuth), app_auth
259          assert isinstance(installation_id, int), installation_id
260          assert token_permissions is None or isinstance(token_permissions, dict), token_permissions
261  
262          self._app_auth = app_auth
263          self._installation_id = installation_id
264          self._token_permissions = token_permissions
265  
266          if requester is not None:
267              self.withRequester(requester)
268  
269      def withRequester(self, requester: Requester) -> "AppInstallationAuth":
270          super().withRequester(requester.withAuth(self._app_auth))
271  
272          from github.GithubIntegration import GithubIntegration
273  
274          self.__integration = GithubIntegration(**self.requester.kwargs)
275  
276          return self
277  
278      @property
279      def app_id(self) -> Union[int, str]:
280          return self._app_auth.app_id
281  
282      @property
283      def private_key(self) -> str:
284          return self._app_auth.private_key
285  
286      @property
287      def installation_id(self) -> int:
288          return self._installation_id
289  
290      @property
291      def token_permissions(self) -> Optional[Dict[str, str]]:
292          return self._token_permissions
293  
294      @property
295      def token_type(self) -> str:
296          return "token"
297  
298      @property
299      def token(self) -> str:
300          if self.__installation_authorization is None or self._is_expired:
301              self.__installation_authorization = self._get_installation_authorization()
302          return self.__installation_authorization.token
303  
304      @property
305      def _is_expired(self) -> bool:
306          assert self.__installation_authorization is not None
307          token_expires_at = self.__installation_authorization.expires_at - TOKEN_REFRESH_THRESHOLD_TIMEDELTA
308          return token_expires_at < datetime.now(timezone.utc)
309  
310      def _get_installation_authorization(self) -> InstallationAuthorization:
311          assert self.__integration is not None, "Method withRequester(Requester) must be called first"
312          return self.__integration.get_access_token(
313              self._installation_id,
314              permissions=self._token_permissions,
315          )
316  
317  
318  class AppUserAuth(Auth, WithRequester["AppUserAuth"]):
319      """
320      This class is used to authenticate Requester as a GitHub App on behalf of a user.
321      https://docs.github.com/en/apps/creating-github-apps/authenticating-with-a-github-app/authenticating-with-a-github-app-on-behalf-of-a-user
322      """
323  
324      _client_id: str
325      _client_secret: str
326      _token: str
327      _type: str
328      _scope: Optional[str]
329      _expires_at: Optional[datetime]
330      _refresh_token: Optional[str]
331      _refresh_expires_at: Optional[datetime]
332  
333      # imported here to avoid circular import
334      from github.ApplicationOAuth import ApplicationOAuth
335  
336      __app: ApplicationOAuth
337  
338      def __init__(
339          self,
340          client_id: str,
341          client_secret: str,
342          token: str,
343          token_type: Optional[str] = None,
344          expires_at: Optional[datetime] = None,
345          refresh_token: Optional[str] = None,
346          refresh_expires_at: Optional[datetime] = None,
347          requester: Optional[Requester] = None,
348      ) -> None:
349          super().__init__()
350  
351          assert isinstance(client_id, str)
352          assert len(client_id) > 0
353          assert isinstance(client_secret, str)
354          assert len(client_secret) > 0
355          assert isinstance(token, str)
356          assert len(token) > 0
357          if token_type is not None:
358              assert isinstance(token_type, str)
359              assert len(token_type) > 0
360          assert isinstance(token, str)
361          if token_type is not None:
362              assert isinstance(token_type, str)
363              assert len(token_type) > 0
364          if expires_at is not None:
365              assert isinstance(expires_at, datetime)
366          if refresh_token is not None:
367              assert isinstance(refresh_token, str)
368              assert len(refresh_token) > 0
369          if refresh_expires_at is not None:
370              assert isinstance(refresh_expires_at, datetime)
371  
372          self._client_id = client_id
373          self._client_secret = client_secret
374          self._token = token
375          self._type = token_type or "bearer"
376          self._expires_at = expires_at
377          self._refresh_token = refresh_token
378          self._refresh_expires_at = refresh_expires_at
379  
380          if requester is not None:
381              self.withRequester(requester)
382  
383      @property
384      def token_type(self) -> str:
385          return self._type
386  
387      @property
388      def token(self) -> str:
389          if self._is_expired:
390              self._refresh()
391          return self._token
392  
393      def withRequester(self, requester: Requester) -> "AppUserAuth":
394          super().withRequester(requester.withAuth(None))
395  
396          # imported here to avoid circular import
397          from github.ApplicationOAuth import ApplicationOAuth
398  
399          self.__app = ApplicationOAuth(
400              # take requester given to super().withRequester, not given to this method
401              super().requester,
402              headers={},
403              attributes={
404                  "client_id": self._client_id,
405                  "client_secret": self._client_secret,
406              },
407              completed=False,
408          )
409  
410          return self
411  
412      @property
413      def _is_expired(self) -> bool:
414          return self._expires_at is not None and self._expires_at < datetime.now(timezone.utc)
415  
416      def _refresh(self) -> None:
417          if self._refresh_token is None:
418              raise RuntimeError("Cannot refresh expired token because no refresh token has been provided")
419          if self._refresh_expires_at is not None and self._refresh_expires_at < datetime.now(timezone.utc):
420              raise RuntimeError("Cannot refresh expired token because refresh token also expired")
421  
422          # refresh token
423          token = self.__app.refresh_access_token(self._refresh_token)
424  
425          # update this auth
426          self._token = token.token
427          self._type = token.type
428          self._scope = token.scope
429          self._expires_at = token.expires_at
430          self._refresh_token = token.refresh_token
431          self._refresh_expires_at = token.refresh_expires_at
432  
433      @property
434      def expires_at(self) -> Optional[datetime]:
435          return self._expires_at
436  
437      @property
438      def refresh_token(self) -> Optional[str]:
439          return self._refresh_token
440  
441      @property
442      def refresh_expires_at(self) -> Optional[datetime]:
443          return self._refresh_expires_at
444  
445  
446  class NetrcAuth(HTTPBasicAuth, WithRequester["NetrcAuth"]):
447      """
448      This class is used to authenticate Requester via .netrc.
449      """
450  
451      def __init__(self) -> None:
452          super().__init__()
453  
454          self._login: Optional[str] = None
455          self._password: Optional[str] = None
456  
457      @property
458      def username(self) -> str:
459          return self.login
460  
461      @property
462      def login(self) -> str:
463          assert self._login is not None, "Method withRequester(Requester) must be called first"
464          return self._login
465  
466      @property
467      def password(self) -> str:
468          assert self._password is not None, "Method withRequester(Requester) must be called first"
469          return self._password
470  
471      def withRequester(self, requester: Requester) -> "NetrcAuth":
472          super().withRequester(requester)
473  
474          auth = utils.get_netrc_auth(requester.base_url, raise_errors=True)
475          if auth is None:
476              raise RuntimeError(f"Could not get credentials from netrc for host {requester.hostname}")
477  
478          self._login, self._password = auth
479  
480          return self