tgstation-server 6.9.2
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
18
20{
23 {
28 const uint ClientCacheDays = 7;
29
33 const string DefaultCacheKey = "~!@TGS_DEFAULT_GITHUB_CLIENT_CACHE_KEY@!~";
34
39
43 readonly ILogger<GitHubClientFactory> logger;
44
49
53 readonly Dictionary<string, (GitHubClient Client, DateTimeOffset LastUsed)> clientCache;
54
58 readonly SemaphoreSlim clientCacheSemaphore;
59
68 ILogger<GitHubClientFactory> logger,
69 IOptions<GeneralConfiguration> generalConfigurationOptions)
70 {
71 this.assemblyInformationProvider = assemblyInformationProvider ?? throw new ArgumentNullException(nameof(assemblyInformationProvider));
72 this.logger = logger ?? throw new ArgumentNullException(nameof(logger));
73 generalConfiguration = generalConfigurationOptions?.Value ?? throw new ArgumentNullException(nameof(generalConfigurationOptions));
74
75 clientCache = new Dictionary<string, (GitHubClient, DateTimeOffset)>();
76 clientCacheSemaphore = new SemaphoreSlim(1, 1);
77 }
78
80 public void Dispose() => clientCacheSemaphore.Dispose();
81
83 public async ValueTask<IGitHubClient> CreateClient(CancellationToken cancellationToken)
84 => (await GetOrCreateClient(
86 null,
87 cancellationToken))!;
88
90 public async ValueTask<IGitHubClient> CreateClient(string accessToken, CancellationToken cancellationToken)
91 => (await GetOrCreateClient(
92 accessToken ?? throw new ArgumentNullException(nameof(accessToken)),
93 null,
94 cancellationToken))!;
95
97 public ValueTask<IGitHubClient?> CreateInstallationClient(string serializedPem, long repositoryId, CancellationToken cancellationToken)
98 => GetOrCreateClient(serializedPem, repositoryId, cancellationToken);
99
107#pragma warning disable CA1506 // TODO: Decomplexify
108 async ValueTask<IGitHubClient?> GetOrCreateClient(string? accessTokenOrSerializedPem, long? installationRepositoryId, CancellationToken cancellationToken)
109#pragma warning restore CA1506
110 {
111 GitHubClient client;
112 bool cacheHit;
113 DateTimeOffset? lastUsed;
114 using (await SemaphoreSlimContext.Lock(clientCacheSemaphore, cancellationToken))
115 {
116 string cacheKey;
117 if (String.IsNullOrWhiteSpace(accessTokenOrSerializedPem))
118 {
119 accessTokenOrSerializedPem = null;
120 cacheKey = DefaultCacheKey;
121 }
122 else
123 cacheKey = accessTokenOrSerializedPem;
124
125 cacheHit = clientCache.TryGetValue(cacheKey, out var tuple);
126
127 var now = DateTimeOffset.UtcNow;
128 if (!cacheHit)
129 {
130 logger.LogTrace("Creating new GitHubClient...");
132 client = new GitHubClient(
133 new ProductHeaderValue(
134 product.Name,
135 product.Version));
136
137 if (accessTokenOrSerializedPem != null)
138 {
139 if (installationRepositoryId.HasValue)
140 {
141 logger.LogTrace("Performing GitHub App authentication for installation on repository {installationRepositoryId}", installationRepositoryId.Value);
142 var splits = accessTokenOrSerializedPem.Split(':');
143 if (splits.Length != 2)
144 {
145 logger.LogError("Failed to parse serialized Client ID & PEM! Expected 2 chunks, got {chunkCount}", splits.Length);
146 return null;
147 }
148
149 byte[] pemBytes;
150 try
151 {
152 pemBytes = Convert.FromBase64String(splits[1]);
153 }
154 catch (Exception ex)
155 {
156 logger.LogError(ex, "Failed to parse supposed base64 PEM!");
157 return null;
158 }
159
160 var pem = Encoding.UTF8.GetString(pemBytes);
161
162 using var rsa = RSA.Create();
163 rsa.ImportFromPem(pem);
164
165 var signingCredentials = new SigningCredentials(new RsaSecurityKey(rsa), SecurityAlgorithms.RsaSha256);
166 var jwtSecurityTokenHandler = new JwtSecurityTokenHandler { SetDefaultTimesOnTokenCreation = false };
167
168 var nowDateTime = DateTime.UtcNow;
169
170 var jwt = jwtSecurityTokenHandler.CreateToken(new SecurityTokenDescriptor
171 {
172 Issuer = splits[0],
173 Expires = nowDateTime.AddMinutes(10),
174 IssuedAt = nowDateTime,
175 SigningCredentials = signingCredentials,
176 });
177
178 var jwtStr = jwtSecurityTokenHandler.WriteToken(jwt);
179
180 client.Credentials = new Credentials(jwtStr, AuthenticationType.Bearer);
181
182 Installation installation;
183 try
184 {
185 installation = await client.GitHubApps.GetRepositoryInstallationForCurrent(installationRepositoryId.Value);
186 }
187 catch (Exception ex)
188 {
189 logger.LogError(ex, "Failed to perform app authentication!");
190 return null;
191 }
192
193 cancellationToken.ThrowIfCancellationRequested();
194 try
195 {
196 var installToken = await client.GitHubApps.CreateInstallationToken(installation.Id);
197
198 client.Credentials = new Credentials(installToken.Token);
199 }
200 catch (Exception ex)
201 {
202 logger.LogError(ex, "Failed to perform installation authentication!");
203 return null;
204 }
205 }
206 else
207 client.Credentials = new Credentials(accessTokenOrSerializedPem);
208 }
209
210 clientCache.Add(cacheKey, (Client: client, LastUsed: now));
211 lastUsed = null;
212 }
213 else
214 {
215 logger.LogTrace("Cache hit for GitHubClient");
216 client = tuple.Client;
217 lastUsed = tuple.LastUsed;
218 tuple.LastUsed = now;
219 }
220
221 // Prune the cache
222 var purgeCount = 0U;
223 var purgeAfter = now.AddDays(-ClientCacheDays);
224 foreach (var key in clientCache.Keys.ToList())
225 {
226 if (key == cacheKey)
227 continue; // save the hash lookup
228
229 tuple = clientCache[key];
230 if (tuple.LastUsed <= purgeAfter)
231 {
232 clientCache.Remove(key);
233 ++purgeCount;
234 }
235 }
236
237 if (purgeCount > 0)
238 logger.LogDebug(
239 "Pruned {count} expired GitHub client(s) from cache that haven't been used in {purgeAfterHours} days.",
240 purgeCount,
242 }
243
244 var rateLimitInfo = client.GetLastApiInfo()?.RateLimit;
245 if (rateLimitInfo != null)
246 if (rateLimitInfo.Remaining == 0)
247 logger.LogWarning(
248 "Requested GitHub client has no requests remaining! Limit resets at {resetTime}",
249 rateLimitInfo.Reset.ToString("o"));
250 else if (rateLimitInfo.Remaining < 25) // good luck hitting these lines on codecov
251 logger.LogWarning(
252 "Requested GitHub client has only {remainingRequests} requests remaining after the usage at {lastUse}! Limit resets at {resetTime}",
253 rateLimitInfo.Remaining,
254 lastUsed,
255 rateLimitInfo.Reset.ToString("o"));
256 else
257 logger.LogDebug(
258 "Requested GitHub client has {remainingRequests} requests remaining after the usage at {lastUse}. Limit resets at {resetTime}",
259 rateLimitInfo.Remaining,
260 lastUsed,
261 rateLimitInfo.Reset.ToString("o"));
262
263 return client;
264 }
265 }
266}
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.
async ValueTask< IGitHubClient > CreateClient(CancellationToken cancellationToken)
Create a IGitHubClient client. Low rate limit unless the server's GitHubAccessToken is set to bypass ...
async ValueTask< IGitHubClient?> GetOrCreateClient(string? accessTokenOrSerializedPem, long? installationRepositoryId, CancellationToken cancellationToken)
Retrieve a GitHubClient from the clientCache or add a new one based on a given accessTokenOrSerialize...
readonly SemaphoreSlim clientCacheSemaphore
The SemaphoreSlim used to guard access to clientCache.
readonly IAssemblyInformationProvider assemblyInformationProvider
The IAssemblyInformationProvider for the GitHubClientFactory.
readonly ILogger< GitHubClientFactory > logger
The ILogger for the GitHubClientFactory.
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.
ValueTask< IGitHubClient?> CreateInstallationClient(string serializedPem, long repositoryId, CancellationToken cancellationToken)
Creates a GitHub App client for an installation.A ValueTask<TResult> resulting in a new IGitHubClient...
const uint ClientCacheDays
Limit to the amount of days a GitHubClient can live in the clientCache.
static async ValueTask< SemaphoreSlimContext > Lock(SemaphoreSlim semaphore, CancellationToken cancellationToken)
Asyncronously locks a semaphore .
ProductInfoHeaderValue ProductInfoHeaderValue
The ProductInfoHeaderValue for the assembly.