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