tgstation-server 6.12.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.Security.Cryptography;
6using System.Text;
7using System.Threading;
8using System.Threading.Tasks;
9
10using Microsoft.Extensions.Logging;
11using Microsoft.Extensions.Options;
12using Microsoft.IdentityModel.Tokens;
13
14using Octokit;
15
19
21{
24 {
29 const uint ClientCacheHours = 1;
30
34 const string DefaultCacheKey = "~!@TGS_DEFAULT_GITHUB_CLIENT_CACHE_KEY@!~";
35
40
44 readonly ILogger<GitHubClientFactory> logger;
45
50
54 readonly Dictionary<string, (GitHubClient Client, DateTimeOffset LastUsed)> clientCache;
55
59 readonly SemaphoreSlim clientCacheSemaphore;
60
69 ILogger<GitHubClientFactory> logger,
70 IOptions<GeneralConfiguration> generalConfigurationOptions)
71 {
72 this.assemblyInformationProvider = assemblyInformationProvider ?? throw new ArgumentNullException(nameof(assemblyInformationProvider));
73 this.logger = logger ?? throw new ArgumentNullException(nameof(logger));
74 generalConfiguration = generalConfigurationOptions?.Value ?? throw new ArgumentNullException(nameof(generalConfigurationOptions));
75
76 clientCache = new Dictionary<string, (GitHubClient, DateTimeOffset)>();
77 clientCacheSemaphore = new SemaphoreSlim(1, 1);
78 }
79
81 public void Dispose() => clientCacheSemaphore.Dispose();
82
84 public async ValueTask<IGitHubClient> CreateClient(CancellationToken cancellationToken)
85 => (await GetOrCreateClient(
87 null,
88 cancellationToken))!;
89
91 public async ValueTask<IGitHubClient> CreateClient(string accessToken, CancellationToken cancellationToken)
92 => (await GetOrCreateClient(
93 accessToken ?? throw new ArgumentNullException(nameof(accessToken)),
94 null,
95 cancellationToken))!;
96
98 public ValueTask<IGitHubClient?> CreateClientForRepository(string accessString, RepositoryIdentifier repositoryIdentifier, CancellationToken cancellationToken)
99 => GetOrCreateClient(accessString, repositoryIdentifier, cancellationToken);
100
102 public IGitHubClient? CreateAppClient(string tgsEncodedAppPrivateKey)
103 => CreateAppClientInternal(tgsEncodedAppPrivateKey ?? throw new ArgumentNullException(nameof(tgsEncodedAppPrivateKey)));
104
112#pragma warning disable CA1506 // TODO: Decomplexify
113 async ValueTask<IGitHubClient?> GetOrCreateClient(string? accessString, RepositoryIdentifier? repositoryIdentifier, CancellationToken cancellationToken)
114#pragma warning restore CA1506
115 {
116 GitHubClient? client;
117 bool cacheHit;
118 DateTimeOffset? lastUsed;
119 using (await SemaphoreSlimContext.Lock(clientCacheSemaphore, cancellationToken))
120 {
121 string cacheKey;
122 if (String.IsNullOrWhiteSpace(accessString))
123 {
124 accessString = null;
125 cacheKey = DefaultCacheKey;
126 }
127 else
128 cacheKey = accessString;
129
130 cacheHit = clientCache.TryGetValue(cacheKey, out var tuple);
131
132 var now = DateTimeOffset.UtcNow;
133 if (!cacheHit)
134 {
135 logger.LogTrace("Creating new GitHubClient...");
136
137 if (accessString != null)
138 {
139 if (accessString.StartsWith(RepositorySettings.TgsAppPrivateKeyPrefix))
140 {
141 if (repositoryIdentifier == null)
142 throw new InvalidOperationException("Cannot create app installation key without target repositoryIdentifier!");
143
144 logger.LogTrace("Performing GitHub App authentication for installation on repository {installationRepositoryId}", repositoryIdentifier);
145
146 client = CreateAppClientInternal(accessString);
147 if (client == null)
148 return null;
149
150 Installation installation;
151 try
152 {
153 var installationTask = repositoryIdentifier.IsSlug
154 ? client.GitHubApps.GetRepositoryInstallationForCurrent(repositoryIdentifier.Owner, repositoryIdentifier.Name)
155 : client.GitHubApps.GetRepositoryInstallationForCurrent(repositoryIdentifier.RepositoryId.Value);
156 installation = await installationTask;
157 }
158 catch (Exception ex)
159 {
160 logger.LogError(ex, "Failed to perform app authentication!");
161 return null;
162 }
163
164 cancellationToken.ThrowIfCancellationRequested();
165 try
166 {
167 var installToken = await client.GitHubApps.CreateInstallationToken(installation.Id);
168
169 client.Credentials = new Credentials(installToken.Token);
170 }
171 catch (Exception ex)
172 {
173 logger.LogError(ex, "Failed to perform installation authentication!");
174 return null;
175 }
176 }
177 else
178 {
180 client.Credentials = new Credentials(accessString);
181 }
182 }
183 else
185
186 clientCache.Add(cacheKey, (Client: client, LastUsed: now));
187 lastUsed = null;
188 }
189 else
190 {
191 logger.LogTrace("Cache hit for GitHubClient");
192 client = tuple.Client;
193 lastUsed = tuple.LastUsed;
194 tuple.LastUsed = now;
195 }
196
197 // Prune the cache
198 var purgeCount = 0U;
199 var purgeAfter = now.AddHours(-ClientCacheHours);
200 foreach (var key in clientCache.Keys.ToList())
201 {
202 if (key == cacheKey)
203 continue; // save the hash lookup
204
205 tuple = clientCache[key];
206 if (tuple.LastUsed <= purgeAfter)
207 {
208 clientCache.Remove(key);
209 ++purgeCount;
210 }
211 }
212
213 if (purgeCount > 0)
214 logger.LogDebug(
215 "Pruned {count} expired GitHub client(s) from cache that haven't been used in {purgeAfterHours} hours.",
216 purgeCount,
218 }
219
220 var rateLimitInfo = client.GetLastApiInfo()?.RateLimit;
221 if (rateLimitInfo != null)
222 if (rateLimitInfo.Remaining == 0)
223 logger.LogWarning(
224 "Requested GitHub client has no requests remaining! Limit resets at {resetTime}",
225 rateLimitInfo.Reset.ToString("o"));
226 else if (rateLimitInfo.Remaining < 25) // good luck hitting these lines on codecov
227 logger.LogWarning(
228 "Requested GitHub client has only {remainingRequests} requests remaining after the usage at {lastUse}! Limit resets at {resetTime}",
229 rateLimitInfo.Remaining,
230 lastUsed,
231 rateLimitInfo.Reset.ToString("o"));
232 else
233 logger.LogDebug(
234 "Requested GitHub client has {remainingRequests} requests remaining after the usage at {lastUse}. Limit resets at {resetTime}",
235 rateLimitInfo.Remaining,
236 lastUsed,
237 rateLimitInfo.Reset.ToString("o"));
238
239 return client;
240 }
241
247 GitHubClient? CreateAppClientInternal(string tgsEncodedAppPrivateKey)
248 {
249 var client = CreateUnauthenticatedClient();
250 var splits = tgsEncodedAppPrivateKey.Split(':');
251 if (splits.Length != 2)
252 {
253 logger.LogError("Failed to parse serialized Client ID & PEM! Expected 2 chunks, got {chunkCount}", splits.Length);
254 return null;
255 }
256
257 byte[] pemBytes;
258 try
259 {
260 pemBytes = Convert.FromBase64String(splits[1]);
261 }
262 catch (Exception ex)
263 {
264 logger.LogError(ex, "Failed to parse supposed base64 PEM!");
265 return null;
266 }
267
268 var pem = Encoding.UTF8.GetString(pemBytes);
269
270 using var rsa = RSA.Create();
271
272 try
273 {
274 rsa.ImportFromPem(pem);
275 }
276 catch (Exception ex)
277 {
278 logger.LogWarning(ex, "Failed to parse PEM!");
279 return null;
280 }
281
282 var signingCredentials = new SigningCredentials(
283 new RsaSecurityKey(rsa),
284 SecurityAlgorithms.RsaSha256)
285 {
286 // https://stackoverflow.com/questions/62307933/rsa-disposed-object-error-every-other-test
287 CryptoProviderFactory = new CryptoProviderFactory
288 {
289 CacheSignatureProviders = false,
290 },
291 };
292 var jwtSecurityTokenHandler = new JwtSecurityTokenHandler { SetDefaultTimesOnTokenCreation = false };
293
294 var nowDateTime = DateTime.UtcNow;
295
296 var appOrClientId = splits[0][RepositorySettings.TgsAppPrivateKeyPrefix.Length..];
297
298 var jwt = jwtSecurityTokenHandler.CreateToken(new SecurityTokenDescriptor
299 {
300 Issuer = appOrClientId,
301 Expires = nowDateTime.AddMinutes(10),
302 IssuedAt = nowDateTime,
303 SigningCredentials = signingCredentials,
304 });
305
306 var jwtStr = jwtSecurityTokenHandler.WriteToken(jwt);
307 client.Credentials = new Credentials(jwtStr, AuthenticationType.Bearer);
308 return client;
309 }
310
316 {
318 return new GitHubClient(
319 new ProductHeaderValue(
320 product.Name,
321 product.Version));
322 }
323 }
324}
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....
const string DefaultCacheKey
The clientCache KeyValuePair<TKey, TValue>.Key used in place of null when accessing a configuration-b...
readonly Dictionary< string,(GitHubClient Client, DateTimeOffset LastUsed)> clientCache
Cache of created GitHubClients and last used times, keyed by access token.
GitHubClientFactory(IAssemblyInformationProvider assemblyInformationProvider, ILogger< GitHubClientFactory > logger, IOptions< GeneralConfiguration > generalConfigurationOptions)
Initializes a new instance of the GitHubClientFactory class.
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.
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.
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.