Skip to content

Commit aade338

Browse files
fix: Tokens in config are no longer ignored when there are tokens in the environment, part of refactoring in preparation for app token refreshing (#284)
This PR continues the work started in #281, completing the major refactor. This is accomplished by creating PersonalTokenManager and AppTokenManager classes, moving logic to them, and adding tests. After this PR, I'll be able to easily implement refreshing for app tokens and other app token related features. In additional to the added unit tests, I have tested running an extractor on top of the proposed code, in a couple scenarios: with auth_token? | with additional_auth_tokens? | with personal tokens in env? | with app token? | # tokens that should be used | # tokens used in test run --|--|--|--|--|-- no | no | no | yes | 1 | 1 ✅ yes | yes (1) | no | yes | 3 | 3 ✅ no | yes (2) | no | yes | 3 | 3 ✅ I am not easily able to test the case of personal tokens being detected from the environment, just because changing the name of environment variables is not simple in my set up. --------- Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
1 parent b6074c2 commit aade338

File tree

2 files changed

+408
-35
lines changed

2 files changed

+408
-35
lines changed

tap_github/authenticator.py

+95-34
Original file line numberDiff line numberDiff line change
@@ -3,10 +3,10 @@
33
import logging
44
import time
55
from copy import deepcopy
6-
from datetime import datetime
6+
from datetime import datetime, timedelta
77
from os import environ
88
from random import choice, shuffle
9-
from typing import Any, Dict, List, Optional, Set
9+
from typing import Any, Dict, List, Optional, Set, Tuple
1010

1111
import jwt
1212
import requests
@@ -15,7 +15,9 @@
1515

1616

1717
class TokenManager:
18-
"""A class to store a token's attributes and state."""
18+
"""A class to store a token's attributes and state.
19+
This parent class should not be used directly, use a subclass instead.
20+
"""
1921

2022
DEFAULT_RATE_LIMIT = 5000
2123
# The DEFAULT_RATE_LIMIT_BUFFER buffer serves two purposes:
@@ -25,7 +27,7 @@ class TokenManager:
2527

2628
def __init__(
2729
self,
28-
token: str,
30+
token: Optional[str],
2931
rate_limit_buffer: Optional[int] = None,
3032
logger: Optional[Any] = None,
3133
):
@@ -50,6 +52,9 @@ def update_rate_limit(self, response_headers: Any) -> None:
5052

5153
def is_valid_token(self) -> bool:
5254
"""Try making a request with the current token. If the request succeeds return True, else False."""
55+
if not self.token:
56+
return False
57+
5358
try:
5459
response = requests.get(
5560
url="https://api.github.com/rate_limit",
@@ -61,7 +66,7 @@ def is_valid_token(self) -> bool:
6166
return True
6267
except requests.exceptions.HTTPError:
6368
msg = (
64-
f"A token was dismissed. "
69+
f"A token could not be validated. "
6570
f"{response.status_code} Client Error: "
6671
f"{str(response.content)} (Reason: {response.reason})"
6772
)
@@ -70,7 +75,7 @@ def is_valid_token(self) -> bool:
7075
return False
7176

7277
def has_calls_remaining(self) -> bool:
73-
"""Check if token is valid.
78+
"""Check if a token has capacity to make more calls.
7479
7580
Returns:
7681
True if the token is valid and has enough api calls remaining.
@@ -85,6 +90,14 @@ def has_calls_remaining(self) -> bool:
8590
return True
8691

8792

93+
class PersonalTokenManager(TokenManager):
94+
"""A class to store token rate limiting information."""
95+
96+
def __init__(self, token: str, rate_limit_buffer: Optional[int] = None, **kwargs):
97+
"""Init PersonalTokenRateLimit info."""
98+
super().__init__(token, rate_limit_buffer=rate_limit_buffer, **kwargs)
99+
100+
88101
def generate_jwt_token(
89102
github_app_id: str,
90103
github_private_key: str,
@@ -110,7 +123,8 @@ def generate_app_access_token(
110123
github_app_id: str,
111124
github_private_key: str,
112125
github_installation_id: Optional[str] = None,
113-
) -> str:
126+
) -> Tuple[str, datetime]:
127+
produced_at = datetime.now()
114128
jwt_token = generate_jwt_token(github_app_id, github_private_key)
115129

116130
headers = {"Authorization": f"Bearer {jwt_token}"}
@@ -135,14 +149,71 @@ def generate_app_access_token(
135149
if resp.status_code != 201:
136150
resp.raise_for_status()
137151

138-
return resp.json()["token"]
152+
expires_at = produced_at + timedelta(hours=1)
153+
return resp.json()["token"], expires_at
154+
155+
156+
class AppTokenManager(TokenManager):
157+
"""A class to store an app token's attributes and state, and handle token refreshing"""
158+
159+
DEFAULT_RATE_LIMIT = 15000
160+
DEFAULT_EXPIRY_BUFFER_MINS = 10
161+
162+
def __init__(self, env_key: str, rate_limit_buffer: Optional[int] = None, **kwargs):
163+
if rate_limit_buffer is None:
164+
rate_limit_buffer = self.DEFAULT_RATE_LIMIT_BUFFER
165+
super().__init__(None, rate_limit_buffer=rate_limit_buffer, **kwargs)
166+
167+
parts = env_key.split(";;")
168+
self.github_app_id = parts[0]
169+
self.github_private_key = (parts[1:2] or [""])[0].replace("\\n", "\n")
170+
self.github_installation_id: Optional[str] = (parts[2:3] or [""])[0]
171+
172+
self.token_expires_at: Optional[datetime] = None
173+
self.claim_token()
174+
175+
def claim_token(self):
176+
"""Updates the TokenManager's token and token_expires_at attributes.
177+
178+
The outcome will be _either_ that self.token is updated to a newly claimed valid token and
179+
self.token_expires_at is updated to the anticipated expiry time (erring on the side of an early estimate)
180+
_or_ self.token and self.token_expires_at are both set to None.
181+
"""
182+
self.token = None
183+
self.token_expires_at = None
184+
185+
# Make sure we have the details we need
186+
if not self.github_app_id or not self.github_private_key:
187+
raise ValueError(
188+
"GITHUB_APP_PRIVATE_KEY could not be parsed. The expected format is "
189+
'":app_id:;;-----BEGIN RSA PRIVATE KEY-----\\n_YOUR_P_KEY_\\n-----END RSA PRIVATE KEY-----"'
190+
)
191+
192+
self.token, self.token_expires_at = generate_app_access_token(
193+
self.github_app_id, self.github_private_key, self.github_installation_id
194+
)
195+
196+
# Check if the token isn't valid. If not, overwrite it with None
197+
if not self.is_valid_token():
198+
if self.logger:
199+
self.logger.warning(
200+
"An app token was generated but could not be validated."
201+
)
202+
self.token = None
203+
self.token_expires_at = None
139204

140205

141206
class GitHubTokenAuthenticator(APIAuthenticatorBase):
142207
"""Base class for offloading API auth."""
143208

209+
@staticmethod
210+
def get_env():
211+
return dict(environ)
212+
144213
def prepare_tokens(self) -> List[TokenManager]:
145-
# Save GitHub tokens
214+
"""Prep GitHub tokens"""
215+
216+
env_dict = self.get_env()
146217
rate_limit_buffer = self._config.get("rate_limit_buffer", None)
147218

148219
personal_tokens: Set[str] = set()
@@ -156,52 +227,42 @@ def prepare_tokens(self) -> List[TokenManager]:
156227
# Accept multiple tokens using environment variables GITHUB_TOKEN*
157228
env_tokens = {
158229
value
159-
for key, value in environ.items()
230+
for key, value in env_dict.items()
160231
if key.startswith("GITHUB_TOKEN")
161232
}
162233
if len(env_tokens) > 0:
163234
self.logger.info(
164235
f"Found {len(env_tokens)} 'GITHUB_TOKEN' environment variables for authentication."
165236
)
166-
personal_tokens = env_tokens
237+
personal_tokens = personal_tokens.union(env_tokens)
167238

168239
token_managers: List[TokenManager] = []
169240
for token in personal_tokens:
170-
token_manager = TokenManager(
241+
token_manager = PersonalTokenManager(
171242
token, rate_limit_buffer=rate_limit_buffer, logger=self.logger
172243
)
173244
if token_manager.is_valid_token():
174245
token_managers.append(token_manager)
246+
else:
247+
logging.warn("A token was dismissed.")
175248

176249
# Parse App level private key and generate a token
177-
if "GITHUB_APP_PRIVATE_KEY" in environ.keys():
250+
if "GITHUB_APP_PRIVATE_KEY" in env_dict.keys():
178251
# To simplify settings, we use a single env-key formatted as follows:
179252
# "{app_id};;{-----BEGIN RSA PRIVATE KEY-----\n_YOUR_PRIVATE_KEY_\n-----END RSA PRIVATE KEY-----}"
180-
parts = environ["GITHUB_APP_PRIVATE_KEY"].split(";;")
181-
github_app_id = parts[0]
182-
github_private_key = (parts[1:2] or [""])[0].replace("\\n", "\n")
183-
github_installation_id = (parts[2:3] or [""])[0]
184-
185-
if not (github_private_key):
186-
self.logger.warning(
187-
"GITHUB_APP_PRIVATE_KEY could not be parsed. The expected format is "
188-
'":app_id:;;-----BEGIN RSA PRIVATE KEY-----\n_YOUR_P_KEY_\n-----END RSA PRIVATE KEY-----"'
253+
env_key = env_dict["GITHUB_APP_PRIVATE_KEY"]
254+
try:
255+
app_token_manager = AppTokenManager(
256+
env_key, rate_limit_buffer=rate_limit_buffer, logger=self.logger
189257
)
190-
191-
else:
192-
app_token = generate_app_access_token(
193-
github_app_id, github_private_key, github_installation_id or None
194-
)
195-
token_manager = TokenManager(
196-
app_token, rate_limit_buffer=rate_limit_buffer, logger=self.logger
258+
if app_token_manager.is_valid_token():
259+
token_managers.append(app_token_manager)
260+
except ValueError as e:
261+
self.logger.warn(
262+
f"An error was thrown while preparing an app token: {e}"
197263
)
198-
if token_manager.is_valid_token():
199-
token_managers.append(token_manager)
200264

201265
self.logger.info(f"Tap will run with {len(token_managers)} auth tokens")
202-
203-
# Create a dict of TokenManager
204-
# TODO - separate app_token and add logic to refresh the token using generate_app_access_token.
205266
return token_managers
206267

207268
def __init__(self, stream: RESTStream) -> None:

0 commit comments

Comments
 (0)