tgstation-server 6.16.0
The /tg/station 13 server suite
Loading...
Searching...
No Matches
GitHubClientFactory.cs
Go to the documentation of this file.
1using System;
2using System.Collections.Generic;
3using System.IdentityModel.Tokens.Jwt;
4using System.Linq;
5using System.Net.Http;
6using System.Security.Cryptography;
7using System.Text;
8using System.Threading;
9using System.Threading.Tasks;
10
11using Microsoft.Extensions.Logging;
12using Microsoft.Extensions.Options;
13using Microsoft.IdentityModel.Tokens;
14
15using Octokit;
16using Octokit.Internal;
17
21
23{
26 {
31 const uint ClientCacheHours = 1;
32
37
41 const string DefaultCacheKey = "~!@TGS_DEFAULT_GITHUB_CLIENT_CACHE_KEY@!~";
42
47
51 readonly IHttpMessageHandlerFactory httpMessageHandlerFactory;
52
56 readonly ILogger<GitHubClientFactory> logger;
57
62
66 readonly Dictionary<string, (GitHubClient Client, DateTimeOffset LastUsed, DateTimeOffset? Expiry)> clientCache;
67
71 readonly SemaphoreSlim clientCacheSemaphore;
72
82 IHttpMessageHandlerFactory httpMessageHandlerFactory,
83 ILogger<GitHubClientFactory> logger,
84 IOptions<GeneralConfiguration> generalConfigurationOptions)
85 {
86 this.assemblyInformationProvider = assemblyInformationProvider ?? throw new ArgumentNullException(nameof(assemblyInformationProvider));
87 this.httpMessageHandlerFactory = httpMessageHandlerFactory ?? throw new ArgumentNullException(nameof(httpMessageHandlerFactory));
88 this.logger = logger ?? throw new ArgumentNullException(nameof(logger));
89 generalConfiguration = generalConfigurationOptions?.Value ?? throw new ArgumentNullException(nameof(generalConfigurationOptions));
90
91 clientCache = new Dictionary<string, (GitHubClient, DateTimeOffset, DateTimeOffset?)>();
92 clientCacheSemaphore = new SemaphoreSlim(1, 1);
93 }
94
96 public void Dispose() => clientCacheSemaphore.Dispose();
97
99 public async ValueTask<IGitHubClient> CreateClient(CancellationToken cancellationToken)
100 => (await GetOrCreateClient(
102 null,
103 cancellationToken))!;
104
106 public async ValueTask<IGitHubClient> CreateClient(string accessToken, CancellationToken cancellationToken)
107 => (await GetOrCreateClient(
108 accessToken ?? throw new ArgumentNullException(nameof(accessToken)),
109 null,
110 cancellationToken))!;
111
113 public ValueTask<IGitHubClient?> CreateClientForRepository(string accessString, RepositoryIdentifier repositoryIdentifier, CancellationToken cancellationToken)
114 => GetOrCreateClient(accessString, repositoryIdentifier, cancellationToken);
115
117 public IGitHubClient? CreateAppClient(string tgsEncodedAppPrivateKey)
118 => CreateAppClientInternal(tgsEncodedAppPrivateKey ?? throw new ArgumentNullException(nameof(tgsEncodedAppPrivateKey)));
119
127#pragma warning disable CA1506 // TODO: Decomplexify
128 async ValueTask<IGitHubClient?> GetOrCreateClient(string? accessString, RepositoryIdentifier? repositoryIdentifier, CancellationToken cancellationToken)
129#pragma warning restore CA1506
130 {
131 GitHubClient? client;
132 bool cacheHit;
133 DateTimeOffset? lastUsed;
134 using (await SemaphoreSlimContext.Lock(clientCacheSemaphore, cancellationToken))
135 {
136 string cacheKey;
137 if (String.IsNullOrWhiteSpace(accessString))
138 {
139 accessString = null;
140 cacheKey = DefaultCacheKey;
141 }
142 else
143 cacheKey = accessString;
144
145 var now = DateTimeOffset.UtcNow;
146 cacheHit = clientCache.TryGetValue(cacheKey, out var tuple);
147 var tokenValid = cacheHit && (!tuple.Expiry.HasValue || tuple.Expiry.Value <= now);
148 if (!tokenValid)
149 {
150 if (cacheHit)
151 {
152 logger.LogDebug("Previously cached GitHub token has expired!");
153 clientCache.Remove(cacheKey);
154 }
155
156 logger.LogTrace("Creating new GitHubClient...");
157
158 DateTimeOffset? expiry = null;
159 if (accessString != null)
160 {
161 if (accessString.StartsWith(RepositorySettings.TgsAppPrivateKeyPrefix))
162 {
163 if (repositoryIdentifier == null)
164 throw new InvalidOperationException("Cannot create app installation key without target repositoryIdentifier!");
165
166 logger.LogTrace("Performing GitHub App authentication for installation on repository {installationRepositoryId}", repositoryIdentifier);
167
168 client = CreateAppClientInternal(accessString);
169 if (client == null)
170 return null;
171
172 Installation installation;
173 try
174 {
175 var installationTask = repositoryIdentifier.IsSlug
176 ? client.GitHubApps.GetRepositoryInstallationForCurrent(repositoryIdentifier.Owner, repositoryIdentifier.Name)
177 : client.GitHubApps.GetRepositoryInstallationForCurrent(repositoryIdentifier.RepositoryId.Value);
178 installation = await installationTask;
179 }
180 catch (Exception ex)
181 {
182 logger.LogError(ex, "Failed to perform app authentication!");
183 return null;
184 }
185
186 cancellationToken.ThrowIfCancellationRequested();
187 try
188 {
189 var installToken = await client.GitHubApps.CreateInstallationToken(installation.Id);
190
191 client.Credentials = new Credentials(installToken.Token);
192 expiry = installToken.ExpiresAt.AddMinutes(-AppTokenExpiryGraceMinutes);
193 }
194 catch (Exception ex)
195 {
196 logger.LogError(ex, "Failed to perform installation authentication!");
197 return null;
198 }
199 }
200 else
201 {
203 client.Credentials = new Credentials(accessString);
204 }
205 }
206 else
208
209 clientCache.Add(cacheKey, (Client: client, LastUsed: now, Expiry: expiry));
210 lastUsed = null;
211 }
212 else
213 {
214 logger.LogTrace("Cache hit for GitHubClient");
215 client = tuple.Client;
216 lastUsed = tuple.LastUsed;
217 tuple.LastUsed = now;
218 }
219
220 // Prune the cache
221 var purgeCount = 0U;
222 var purgeAfter = now.AddHours(-ClientCacheHours);
223 foreach (var key in clientCache.Keys.ToList())
224 {
225 if (key == cacheKey)
226 continue; // save the hash lookup
227
228 tuple = clientCache[key];
229 if (tuple.LastUsed <= purgeAfter || (tuple.Expiry.HasValue && tuple.Expiry.Value <= now))
230 {
231 clientCache.Remove(key);
232 ++purgeCount;
233 }
234 }
235
236 if (purgeCount > 0)
237 logger.LogDebug(
238 "Pruned {count} expired GitHub client(s) from cache that haven't been used in {purgeAfterHours} hours.",
239 purgeCount,
241 }
242
243 var rateLimitInfo = client.GetLastApiInfo()?.RateLimit;
244 if (rateLimitInfo != null)
245 if (rateLimitInfo.Remaining == 0)
246 logger.LogWarning(
247 "Requested GitHub client has no requests remaining! Limit resets at {resetTime}",
248 rateLimitInfo.Reset.ToString("o"));
249 else if (rateLimitInfo.Remaining < 25) // good luck hitting these lines on codecov
250 logger.LogWarning(
251 "Requested GitHub client has only {remainingRequests} requests remaining after the usage at {lastUse}! Limit resets at {resetTime}",
252 rateLimitInfo.Remaining,
253 lastUsed,
254 rateLimitInfo.Reset.ToString("o"));
255 else
256 logger.LogDebug(
257 "Requested GitHub client has {remainingRequests} requests remaining after the usage at {lastUse}. Limit resets at {resetTime}",
258 rateLimitInfo.Remaining,
259 lastUsed,
260 rateLimitInfo.Reset.ToString("o"));
261
262 return client;
263 }
264
270 GitHubClient? CreateAppClientInternal(string tgsEncodedAppPrivateKey)
271 {
272 var client = CreateUnauthenticatedClient();
273 var splits = tgsEncodedAppPrivateKey.Split(':');
274 if (splits.Length != 2)
275 {
276 logger.LogError("Failed to parse serialized Client ID & PEM! Expected 2 chunks, got {chunkCount}", splits.Length);
277 return null;
278 }
279
280 byte[] pemBytes;
281 try
282 {
283 pemBytes = Convert.FromBase64String(splits[1]);
284 }
285 catch (Exception ex)
286 {
287 logger.LogError(ex, "Failed to parse supposed base64 PEM!");
288 return null;
289 }
290
291 var pem = Encoding.UTF8.GetString(pemBytes);
292
293 using var rsa = RSA.Create();
294
295 try
296 {
297 rsa.ImportFromPem(pem);
298 }
299 catch (Exception ex)
300 {
301 logger.LogWarning(ex, "Failed to parse PEM!");
302 return null;
303 }
304
305 var signingCredentials = new SigningCredentials(
306 new RsaSecurityKey(rsa),
307 SecurityAlgorithms.RsaSha256)
308 {
309 // https://stackoverflow.com/questions/62307933/rsa-disposed-object-error-every-other-test
310 CryptoProviderFactory = new CryptoProviderFactory
311 {
312 CacheSignatureProviders = false,
313 },
314 };
315 var jwtSecurityTokenHandler = new JwtSecurityTokenHandler { SetDefaultTimesOnTokenCreation = false };
316
317 var nowDateTime = DateTime.UtcNow;
318
319 var appOrClientId = splits[0][RepositorySettings.TgsAppPrivateKeyPrefix.Length..];
320
321 var jwt = jwtSecurityTokenHandler.CreateToken(new SecurityTokenDescriptor
322 {
323 Issuer = appOrClientId,
324 Expires = nowDateTime.AddMinutes(10),
325 IssuedAt = nowDateTime,
326 SigningCredentials = signingCredentials,
327 });
328
329 var jwtStr = jwtSecurityTokenHandler.WriteToken(jwt);
330 client.Credentials = new Credentials(jwtStr, AuthenticationType.Bearer);
331 return client;
332 }
333
339 {
341#pragma warning disable CA2000 // Dispose objects before losing scope
342 var handler = httpMessageHandlerFactory.CreateHandler();
343 try
344 {
345 var clientAdapter = new HttpClientAdapter(() => handler);
346#pragma warning restore CA2000 // Dispose objects before losing scope
347 handler = null;
348 try
349 {
350 return new GitHubClient(
351 new Connection(
352 new ProductHeaderValue(
353 product.Name,
354 product.Version),
355 clientAdapter));
356 }
357 catch
358 {
359 clientAdapter.Dispose();
360 throw;
361 }
362 }
363 catch
364 {
365 handler?.Dispose();
366 throw;
367 }
368 }
369 }
370}
Represents configurable settings for a git repository.
const string TgsAppPrivateKeyPrefix
Prefix for TGS encoded app private keys. This is encoded in the format PREFIX + (APP_ID OR CLIENT_ID)...
string? GitHubAccessToken
A classic GitHub personal access token to use for bypassing rate limits on requests....
readonly IHttpMessageHandlerFactory httpMessageHandlerFactory
The IHttpMessageHandlerFactory for the GitHubClientFactory.
const string DefaultCacheKey
The clientCache KeyValuePair<TKey, TValue>.Key used in place of null when accessing a configuration-b...
GitHubClient? CreateAppClientInternal(string tgsEncodedAppPrivateKey)
Create an App (not installation) authenticated GitHubClient.
async ValueTask< IGitHubClient > CreateClient(CancellationToken cancellationToken)
Create a IGitHubClient client. Low rate limit unless the server's GitHubAccessToken is set to bypass ...
IGitHubClient? CreateAppClient(string tgsEncodedAppPrivateKey)
Create an App (not installation) authenticated IGitHubClient.A new app auth IGitHubClient for the giv...
readonly SemaphoreSlim clientCacheSemaphore
The SemaphoreSlim used to guard access to clientCache.
GitHubClient CreateUnauthenticatedClient()
Creates an unauthenticated GitHubClient.
const uint AppTokenExpiryGraceMinutes
Minutes before tokens expire before not using them.
GitHubClientFactory(IAssemblyInformationProvider assemblyInformationProvider, IHttpMessageHandlerFactory httpMessageHandlerFactory, ILogger< GitHubClientFactory > logger, IOptions< GeneralConfiguration > generalConfigurationOptions)
Initializes a new instance of the GitHubClientFactory class.
readonly IAssemblyInformationProvider assemblyInformationProvider
The IAssemblyInformationProvider for the GitHubClientFactory.
readonly ILogger< GitHubClientFactory > logger
The ILogger for the GitHubClientFactory.
async ValueTask< IGitHubClient?> GetOrCreateClient(string? accessString, RepositoryIdentifier? repositoryIdentifier, CancellationToken cancellationToken)
Retrieve a GitHubClient from the clientCache or add a new one based on a given accessString .
async ValueTask< IGitHubClient > CreateClient(string accessToken, CancellationToken cancellationToken)
Create a client with authentication using a personal access token.A new IGitHubClient.
readonly GeneralConfiguration generalConfiguration
The GeneralConfiguration for the GitHubClientFactory.
readonly Dictionary< string,(GitHubClient Client, DateTimeOffset LastUsed, DateTimeOffset? Expiry)> clientCache
Cache of created GitHubClients and last used/expiry times, keyed by access token.
const uint ClientCacheHours
Limit to the amount of hours a GitHubClient can live in the clientCache.
ValueTask< IGitHubClient?> CreateClientForRepository(string accessString, RepositoryIdentifier repositoryIdentifier, CancellationToken cancellationToken)
Creates a GitHub client that will only be used for a given repositoryIdentifier .A ValueTask<TResult>...
Identifies a repository either by its RepositoryId or Owner and Name.
static async ValueTask< SemaphoreSlimContext > Lock(SemaphoreSlim semaphore, CancellationToken cancellationToken, ILogger? logger=null)
Asyncronously locks a semaphore .
ProductInfoHeaderValue ProductInfoHeaderValue
The ProductInfoHeaderValue for the assembly.