Add support for GitHub App installation authentication to IGitHubClientFactory

This commit is contained in:
Jordan Dominion
2024-08-13 21:51:59 -04:00
parent 5488203764
commit a42dafb3ab
13 changed files with 190 additions and 58 deletions
@@ -79,13 +79,13 @@ namespace Tgstation.Server.Host.Components.Deployment.Remote
IGitHubService gitHubService;
if (instanceAuthenticated)
{
authenticatedGitHubService = gitHubServiceFactory.CreateService(repositorySettings.AccessToken!);
authenticatedGitHubService = await gitHubServiceFactory.CreateService(repositorySettings.AccessToken!, cancellationToken);
gitHubService = authenticatedGitHubService;
}
else
{
authenticatedGitHubService = null;
gitHubService = gitHubServiceFactory.CreateService();
gitHubService = await gitHubServiceFactory.CreateService(cancellationToken);
}
var repoOwner = remoteInformation.RemoteRepositoryOwner!;
@@ -175,8 +175,8 @@ namespace Tgstation.Server.Host.Components.Deployment.Remote
}
var gitHubService = repositorySettings.AccessToken != null
? gitHubServiceFactory.CreateService(repositorySettings.AccessToken)
: gitHubServiceFactory.CreateService();
? await gitHubServiceFactory.CreateService(repositorySettings.AccessToken, cancellationToken)
: await gitHubServiceFactory.CreateService(cancellationToken);
var tasks = revisionInformation
.ActiveTestMerges
@@ -255,7 +255,7 @@ namespace Tgstation.Server.Host.Components.Deployment.Remote
int testMergeNumber,
CancellationToken cancellationToken)
{
var gitHubService = gitHubServiceFactory.CreateService(repositorySettings.AccessToken!);
var gitHubService = await gitHubServiceFactory.CreateService(repositorySettings.AccessToken!, cancellationToken);
try
{
@@ -343,7 +343,7 @@ namespace Tgstation.Server.Host.Components.Deployment.Remote
return;
}
var gitHubService = gitHubServiceFactory.CreateService(gitHubAccessToken);
var gitHubService = await gitHubServiceFactory.CreateService(gitHubAccessToken, cancellationToken);
try
{
@@ -49,8 +49,8 @@ namespace Tgstation.Server.Host.Components.Repository
CancellationToken cancellationToken)
{
var gitHubService = repositorySettings.AccessToken != null
? gitHubServiceFactory.CreateService(repositorySettings.AccessToken)
: gitHubServiceFactory.CreateService();
? await gitHubServiceFactory.CreateService(repositorySettings.AccessToken, cancellationToken)
: await gitHubServiceFactory.CreateService(cancellationToken);
PullRequest? pr = null;
ApiException? exception = null;
@@ -165,7 +165,7 @@ namespace Tgstation.Server.Host.Controllers
Uri? repoUrl = null;
try
{
var gitHubService = gitHubServiceFactory.CreateService();
var gitHubService = await gitHubServiceFactory.CreateService(cancellationToken);
var repositoryUrlTask = gitHubService.GetUpdatesRepositoryUrl(cancellationToken);
var releases = await gitHubService.GetTgsReleases(cancellationToken);
@@ -288,7 +288,7 @@ namespace Tgstation.Server.Host.Core
{
logger.LogDebug("Looking for GitHub releases version {version}...", newVersion);
var gitHubService = gitHubServiceFactory.CreateService();
var gitHubService = await gitHubServiceFactory.CreateService(cancellationToken);
var releases = await gitHubService.GetTgsReleases(cancellationToken);
foreach (var kvp in releases)
{
@@ -60,12 +60,12 @@ namespace Tgstation.Server.Host.Security.OAuth
{
logger.LogTrace("Validating response code...");
var gitHubService = gitHubServiceFactory.CreateService();
var gitHubService = await gitHubServiceFactory.CreateService(cancellationToken);
var token = await gitHubService.CreateOAuthAccessToken(oAuthConfiguration, code, cancellationToken);
if (token == null)
return null;
var authenticatedClient = gitHubServiceFactory.CreateService(token);
var authenticatedClient = await gitHubServiceFactory.CreateService(token, cancellationToken);
logger.LogTrace("Getting user details...");
var userId = await authenticatedClient.GetCurrentUserId(cancellationToken);
@@ -1,9 +1,16 @@
using System;
using System.Collections.Generic;
using System.IdentityModel.Tokens.Jwt;
using System.Linq;
using System.Security.Cryptography;
using System.Text;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using Microsoft.IdentityModel.Tokens;
using Octokit;
using Tgstation.Server.Host.Configuration;
@@ -12,7 +19,7 @@ using Tgstation.Server.Host.System;
namespace Tgstation.Server.Host.Utils.GitHub
{
/// <inheritdoc />
sealed class GitHubClientFactory : IGitHubClientFactory
sealed class GitHubClientFactory : IGitHubClientFactory, IDisposable
{
/// <summary>
/// Limit to the amount of days a <see cref="GitHubClient"/> can live in the <see cref="clientCache"/>.
@@ -45,6 +52,11 @@ namespace Tgstation.Server.Host.Utils.GitHub
/// </summary>
readonly Dictionary<string, (GitHubClient Client, DateTimeOffset LastUsed)> clientCache;
/// <summary>
/// The <see cref="SemaphoreSlim"/> used to guard access to <see cref="clientCache"/>.
/// </summary>
readonly SemaphoreSlim clientCacheSemaphore;
/// <summary>
/// Initializes a new instance of the <see cref="GitHubClientFactory"/> class.
/// </summary>
@@ -61,50 +73,139 @@ namespace Tgstation.Server.Host.Utils.GitHub
generalConfiguration = generalConfigurationOptions?.Value ?? throw new ArgumentNullException(nameof(generalConfigurationOptions));
clientCache = new Dictionary<string, (GitHubClient, DateTimeOffset)>();
clientCacheSemaphore = new SemaphoreSlim(1, 1);
}
/// <inheritdoc />
public IGitHubClient CreateClient() => GetOrCreateClient(generalConfiguration.GitHubAccessToken);
public void Dispose() => clientCacheSemaphore.Dispose();
/// <inheritdoc />
public IGitHubClient CreateClient(string accessToken)
=> GetOrCreateClient(
accessToken ?? throw new ArgumentNullException(nameof(accessToken)));
public async ValueTask<IGitHubClient> CreateClient(CancellationToken cancellationToken)
=> (await GetOrCreateClient(
generalConfiguration.GitHubAccessToken,
null,
cancellationToken))!;
/// <inheritdoc />
public async ValueTask<IGitHubClient> CreateClient(string accessToken, CancellationToken cancellationToken)
=> (await GetOrCreateClient(
accessToken ?? throw new ArgumentNullException(nameof(accessToken)),
null,
cancellationToken))!;
/// <inheritdoc />
public ValueTask<IGitHubClient?> CreateInstallationClient(string serializedPem, long repositoryId, CancellationToken cancellationToken)
=> GetOrCreateClient(serializedPem, repositoryId, cancellationToken);
/// <summary>
/// Retrieve a <see cref="GitHubClient"/> from the <see cref="clientCache"/> or add a new one based on a given <paramref name="accessToken"/>.
/// Retrieve a <see cref="GitHubClient"/> from the <see cref="clientCache"/> or add a new one based on a given <paramref name="accessTokenOrSerializedPem"/>.
/// </summary>
/// <param name="accessToken">Optional access token to use as credentials.</param>
/// <returns>The <see cref="GitHubClient"/> for the given <paramref name="accessToken"/>.</returns>
GitHubClient GetOrCreateClient(string? accessToken)
/// <param name="accessTokenOrSerializedPem">Optional access token to use as credentials or GitHub App private key. If using a private key, <paramref name="installationRepositoryId"/> must be set.</param>
/// <param name="installationRepositoryId">Setting this specifies <paramref name="accessTokenOrSerializedPem"/> is a private key and a GitHub App installation authenticated client will be returned.</param>
/// <param name="cancellationToken">The <see cref="CancellationToken"/> for the operation.</param>
/// <returns>A <see cref="ValueTask{TResult}"/> resulting in the <see cref="GitHubClient"/> for the given <paramref name="accessTokenOrSerializedPem"/> or <see langword="null"/> if authentication failed.</returns>
#pragma warning disable CA1506 // TODO: Decomplexify
async ValueTask<IGitHubClient?> GetOrCreateClient(string? accessTokenOrSerializedPem, long? installationRepositoryId, CancellationToken cancellationToken)
#pragma warning restore CA1506
{
GitHubClient client;
bool cacheHit;
DateTimeOffset? lastUsed;
lock (clientCache)
using (await SemaphoreSlimContext.Lock(clientCacheSemaphore, cancellationToken))
{
string cacheKey;
if (String.IsNullOrWhiteSpace(accessToken))
if (String.IsNullOrWhiteSpace(accessTokenOrSerializedPem))
{
accessToken = null;
accessTokenOrSerializedPem = null;
cacheKey = DefaultCacheKey;
}
else
cacheKey = accessToken;
cacheKey = accessTokenOrSerializedPem;
cacheHit = clientCache.TryGetValue(cacheKey, out var tuple);
var now = DateTimeOffset.UtcNow;
if (!cacheHit)
{
logger.LogTrace("Creating new GitHubClient...");
var product = assemblyInformationProvider.ProductInfoHeaderValue.Product!;
client = new GitHubClient(
new ProductHeaderValue(
product.Name,
product.Version));
if (accessToken != null)
client.Credentials = new Credentials(accessToken);
if (accessTokenOrSerializedPem != null)
{
if (installationRepositoryId.HasValue)
{
logger.LogTrace("Performing GitHub App authentication for installation on repository {installationRepositoryId}", installationRepositoryId.Value);
var splits = accessTokenOrSerializedPem.Split(':');
if (splits.Length != 2)
{
logger.LogError("Failed to parse serialized Client ID & PEM! Expected 2 chunks, got {chunkCount}", splits.Length);
return null;
}
byte[] pemBytes;
try
{
pemBytes = Convert.FromBase64String(splits[1]);
}
catch (Exception ex)
{
logger.LogError(ex, "Failed to parse supposed base64 PEM!");
return null;
}
var pem = Encoding.UTF8.GetString(pemBytes);
using var rsa = RSA.Create();
rsa.ImportFromPem(pem);
var signingCredentials = new SigningCredentials(new RsaSecurityKey(rsa), SecurityAlgorithms.RsaSha256);
var jwtSecurityTokenHandler = new JwtSecurityTokenHandler { SetDefaultTimesOnTokenCreation = false };
var nowDateTime = DateTime.UtcNow;
var jwt = jwtSecurityTokenHandler.CreateToken(new SecurityTokenDescriptor
{
Issuer = splits[0],
Expires = nowDateTime.AddMinutes(10),
IssuedAt = nowDateTime,
SigningCredentials = signingCredentials,
});
var jwtStr = jwtSecurityTokenHandler.WriteToken(jwt);
client.Credentials = new Credentials(jwtStr, AuthenticationType.Bearer);
Installation installation;
try
{
installation = await client.GitHubApps.GetRepositoryInstallationForCurrent(installationRepositoryId.Value);
}
catch (Exception ex)
{
logger.LogError(ex, "Failed to perform app authentication!");
return null;
}
cancellationToken.ThrowIfCancellationRequested();
try
{
var installToken = await client.GitHubApps.CreateInstallationToken(installation.Id);
client.Credentials = new Credentials(installToken.Token);
}
catch (Exception ex)
{
logger.LogError(ex, "Failed to perform installation authentication!");
return null;
}
}
else
client.Credentials = new Credentials(accessTokenOrSerializedPem);
}
clientCache.Add(cacheKey, (Client: client, LastUsed: now));
lastUsed = null;
@@ -1,4 +1,6 @@
using System;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
@@ -44,13 +46,16 @@ namespace Tgstation.Server.Host.Utils.GitHub
}
/// <inheritdoc />
public IGitHubService CreateService() => CreateServiceImpl(gitHubClientFactory.CreateClient());
public async ValueTask<IGitHubService> CreateService(CancellationToken cancellationToken)
=> CreateServiceImpl(
await gitHubClientFactory.CreateClient(cancellationToken));
/// <inheritdoc />
public IAuthenticatedGitHubService CreateService(string accessToken)
public async ValueTask<IAuthenticatedGitHubService> CreateService(string accessToken, CancellationToken cancellationToken)
=> CreateServiceImpl(
gitHubClientFactory.CreateClient(
accessToken ?? throw new ArgumentNullException(nameof(accessToken))));
await gitHubClientFactory.CreateClient(
accessToken ?? throw new ArgumentNullException(nameof(accessToken)),
cancellationToken));
/// <summary>
/// Create a <see cref="GitHubService"/>.
@@ -1,4 +1,7 @@
using Octokit;
using System.Threading;
using System.Threading.Tasks;
using Octokit;
namespace Tgstation.Server.Host.Utils.GitHub
{
@@ -10,14 +13,25 @@ namespace Tgstation.Server.Host.Utils.GitHub
/// <summary>
/// Create a <see cref="IGitHubClient"/> client. Low rate limit unless the server's GitHubAccessToken is set to bypass it.
/// </summary>
/// <param name="cancellationToken">The <see cref="CancellationToken"/> for the operation.</param>
/// <returns>A new <see cref="IGitHubClient"/>.</returns>
IGitHubClient CreateClient();
ValueTask<IGitHubClient> CreateClient(CancellationToken cancellationToken);
/// <summary>
/// Create a client with authentication using a personal access token.
/// </summary>
/// <param name="accessToken">The GitHub personal access token.</param>
/// <param name="cancellationToken">The <see cref="CancellationToken"/> for the operation.</param>
/// <returns>A new <see cref="IGitHubClient"/>.</returns>
IGitHubClient CreateClient(string accessToken);
ValueTask<IGitHubClient> CreateClient(string accessToken, CancellationToken cancellationToken);
/// <summary>
/// Creates a GitHub App client for an installation.
/// </summary>
/// <param name="pem">The private key <see cref="string"/>.</param>
/// <param name="repositoryId">The GitHub repository ID.</param>
/// <param name="cancellationToken">The <see cref="CancellationToken"/> for the operation.</param>
/// <returns>A <see cref="ValueTask{TResult}"/> resulting in a new <see cref="IGitHubClient"/> for the given <paramref name="repositoryId"/> or <see langword="null"/> if authentication failed.</returns>
ValueTask<IGitHubClient?> CreateInstallationClient(string pem, long repositoryId, CancellationToken cancellationToken);
}
}
@@ -1,4 +1,7 @@
namespace Tgstation.Server.Host.Utils.GitHub
using System.Threading;
using System.Threading.Tasks;
namespace Tgstation.Server.Host.Utils.GitHub
{
/// <summary>
/// Factory for <see cref="IGitHubService"/>s.
@@ -8,14 +11,16 @@
/// <summary>
/// Create a <see cref="IGitHubService"/>.
/// </summary>
/// <returns>A new <see cref="IGitHubService"/>.</returns>
public IGitHubService CreateService();
/// <param name="cancellationToken">The <see cref="CancellationToken"/> for the operation.</param>
/// <returns>A <see cref="ValueTask{TResult}"/> resulting in a new <see cref="IGitHubService"/>.</returns>
public ValueTask<IGitHubService> CreateService(CancellationToken cancellationToken);
/// <summary>
/// Create an <see cref="IAuthenticatedGitHubService"/>.
/// </summary>
/// <param name="accessToken">The access token to use for communication with GitHub.</param>
/// <returns>A new <see cref="IAuthenticatedGitHubService"/>.</returns>
public IAuthenticatedGitHubService CreateService(string accessToken);
/// <param name="cancellationToken">The <see cref="CancellationToken"/> for the operation.</param>
/// <returns>A <see cref="ValueTask{TResult}"/> resulting in a new <see cref="IAuthenticatedGitHubService"/>.</returns>
public ValueTask<IAuthenticatedGitHubService> CreateService(string accessToken, CancellationToken cancellationToken);
}
}
@@ -1,5 +1,6 @@
using System;
using System.Net.Http.Headers;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.Logging;
@@ -57,14 +58,14 @@ namespace Tgstation.Server.Host.Utils.GitHub.Tests
mockOptions.SetupGet(x => x.Value).Returns(gc);
var factory = new GitHubClientFactory(mockApp.Object, loggerFactory.CreateLogger<GitHubClientFactory>(), mockOptions.Object);
var client = factory.CreateClient();
var client = await factory.CreateClient(CancellationToken.None);
Assert.IsNotNull(client);
var credentials = await client.Connection.CredentialStore.GetCredentials();
Assert.AreEqual(AuthenticationType.Anonymous, credentials.AuthenticationType);
gc.GitHubAccessToken = "asdfasdfasdfasdfasdfasdf";
client = factory.CreateClient();
client = await factory.CreateClient(CancellationToken.None);
Assert.IsNotNull(client);
credentials = await client.Connection.CredentialStore.GetCredentials();
@@ -83,9 +84,9 @@ namespace Tgstation.Server.Host.Utils.GitHub.Tests
mockOptions.SetupGet(x => x.Value).Returns(new GeneralConfiguration());
var factory = new GitHubClientFactory(mockApp.Object, loggerFactory.CreateLogger<GitHubClientFactory>(), mockOptions.Object);
Assert.ThrowsException<ArgumentNullException>(() => factory.CreateClient(null));
await Assert.ThrowsExceptionAsync<ArgumentNullException>(() => factory.CreateClient(null, CancellationToken.None).AsTask());
var client = factory.CreateClient("asdf");
var client = await factory.CreateClient("asdf", CancellationToken.None);
Assert.IsNotNull(client);
var credentials = await client.Connection.CredentialStore.GetCredentials();
@@ -96,7 +97,7 @@ namespace Tgstation.Server.Host.Utils.GitHub.Tests
}
[TestMethod]
public void TestClientCaching()
public async Task TestClientCaching()
{
var mockApp = new Mock<IAssemblyInformationProvider>();
mockApp.SetupGet(x => x.ProductInfoHeaderValue).Returns(new ProductInfoHeaderValue("TGSTests", "1.2.3")).Verifiable();
@@ -105,10 +106,10 @@ namespace Tgstation.Server.Host.Utils.GitHub.Tests
mockOptions.SetupGet(x => x.Value).Returns(new GeneralConfiguration());
var factory = new GitHubClientFactory(mockApp.Object, loggerFactory.CreateLogger<GitHubClientFactory>(), mockOptions.Object);
var client1 = factory.CreateClient();
var client2 = factory.CreateClient("asdf");
var client3 = factory.CreateClient();
var client4 = factory.CreateClient("asdf");
var client1 = await factory.CreateClient(CancellationToken.None);
var client2 = await factory.CreateClient("asdf", CancellationToken.None);
var client3 = await factory.CreateClient(CancellationToken.None);
var client4 = await factory.CreateClient("asdf", CancellationToken.None);
Assert.ReferenceEquals(client1, client3);
Assert.ReferenceEquals(client2, client4);
}
@@ -1,4 +1,6 @@
using System;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
@@ -28,27 +30,29 @@ namespace Tgstation.Server.Host.Utils.GitHub.Tests
}
[TestMethod]
public void TestCreateService()
public async Task TestCreateService()
{
var mockFactory = new Mock<IGitHubClientFactory>();
mockFactory.Setup(x => x.CreateClient()).Returns(Mock.Of<IGitHubClient>()).Verifiable();
#pragma warning disable CA2012 // Use ValueTasks correctly
mockFactory.Setup(x => x.CreateClient(It.IsAny<CancellationToken>())).Returns(ValueTask.FromResult(Mock.Of<IGitHubClient>())).Verifiable();
var mockToken = "asdf";
mockFactory.Setup(x => x.CreateClient(mockToken)).Returns(Mock.Of<IGitHubClient>()).Verifiable();
mockFactory.Setup(x => x.CreateClient(mockToken, It.IsAny<CancellationToken>())).Returns(ValueTask.FromResult(Mock.Of<IGitHubClient>())).Verifiable();
#pragma warning restore CA2012 // Use ValueTasks correctly
var mockOptions = new Mock<IOptions<UpdatesConfiguration>>();
mockOptions.SetupGet(x => x.Value).Returns(new UpdatesConfiguration());
var factory = new GitHubServiceFactory(mockFactory.Object, Mock.Of<ILoggerFactory>(), mockOptions.Object);
Assert.ThrowsException<ArgumentNullException>(() => factory.CreateService(null));
await Assert.ThrowsExceptionAsync<ArgumentNullException>(() => factory.CreateService(null, CancellationToken.None).AsTask());
Assert.AreEqual(0, mockFactory.Invocations.Count);
var result1 = factory.CreateService();
var result1 = await factory.CreateService(CancellationToken.None);
Assert.IsNotNull(result1);
var result2 = factory.CreateService(mockToken);
var result2 = factory.CreateService(mockToken, CancellationToken.None);
Assert.IsNotNull(result2);
mockFactory.VerifyAll();
@@ -1,4 +1,6 @@
using System;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.Logging;
@@ -18,13 +20,13 @@ namespace Tgstation.Server.Tests.Live
this.logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
public IGitHubService CreateService() => CreateDummyService();
public ValueTask<IGitHubService> CreateService(CancellationToken cancellationToken) => ValueTask.FromResult<IGitHubService>(CreateDummyService());
public IAuthenticatedGitHubService CreateService(string accessToken)
public ValueTask<IAuthenticatedGitHubService> CreateService(string accessToken, CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(accessToken);
return CreateDummyService();
return ValueTask.FromResult<IAuthenticatedGitHubService>(CreateDummyService());
}
TestingGitHubService CreateDummyService() => new TestingGitHubService(cryptographySuite, logger);
@@ -39,7 +39,7 @@ namespace Tgstation.Server.Tests.Live
});
var gitHubClientFactory = new GitHubClientFactory(new AssemblyInformationProvider(), Mock.Of<ILogger<GitHubClientFactory>>(), mockOptions.Object);
RealClient = gitHubClientFactory.CreateClient();
RealClient = gitHubClientFactory.CreateClient(CancellationToken.None).GetAwaiter().GetResult();
}
public static async Task InitializeAndInject(CancellationToken cancellationToken)