Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

GitHub Apps Proof-of-Concept #69

Closed
wants to merge 12 commits into from
5 changes: 5 additions & 0 deletions .github/workflows/build.yml
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,11 @@ jobs:
with:
dotnet-version: '8.x.x'

# This is a temporary step. Since the CLI references the main project locally, the main
# project must be built first. When the CLI is removed, this may be removed as well.
- name: Build main project
run: dotnet build src/GitHub.Octokit.SDK.csproj

- name: Format
run: dotnet format --verify-no-changes

Expand Down
3 changes: 2 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -108,4 +108,5 @@ node_modules/
testresults/
[Tt]est[Rr]esult*/
**/coverage.json
coverage/*
coverage/*
*.pem
2 changes: 2 additions & 0 deletions GitHub.Octokit.sln
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,8 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Solution Items", "Solution
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Tests", "test\Tests.csproj", "{7EF0200D-9F10-4A77-8F73-3E26D3EBD326}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "cli", "cli\cli.csproj", "{3A6900D7-23D6-4AE5-B76A-76A05CD336F1}"
EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
Expand Down
77 changes: 77 additions & 0 deletions cli/Program.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
using System.Security.Cryptography;
using System.Text.Json;
using GitHub;
using GitHub.Octokit.Authentication;
using GitHub.Octokit.Client;
using Microsoft.IdentityModel.JsonWebTokens;
using Microsoft.IdentityModel.Tokens;

var installationId = Environment.GetEnvironmentVariable("GITHUB_APP_INSTALLATION_ID") ?? "";
var clientId = Environment.GetEnvironmentVariable("GITHUB_APP_CLIENT_ID") ?? "";
var privateKey = File.ReadAllText(Environment.GetEnvironmentVariable("GITHUB_APP_PRIVATE_KEY_PATH") ?? "");
var jwtToken = CreateJWT(clientId, privateKey);

var client = BuildHttpClient(jwtToken);
var token = GetAppToken(client).Result;

if (string.IsNullOrEmpty(token)) throw new Exception("Failed to get token");

// var token = Environment.GetEnvironmentVariable("GITHUB_TOKEN") ?? "";
var adapter = RequestAdapter.Create(new TokenAuthenticationProvider(token));
// adapter.BaseUrl = "http://api.github.localhost:1024";
var gitHubClient = new GitHubClient(adapter);

try
{
var response = await gitHubClient.Installation.Repositories.GetAsync();
response?.Repositories?.ForEach(repo => Console.WriteLine(repo.FullName));
}
catch (Exception e)
{
Console.WriteLine(e.Message);
}

string CreateJWT(string clientId, string privateKey)
{
var rsa = RSA.Create();
rsa.ImportFromPem(privateKey);

var now = DateTimeOffset.Now;
var tokenDescriptor = new SecurityTokenDescriptor
{
Issuer = clientId,
IssuedAt = now.UtcDateTime,
NotBefore = now.UtcDateTime,
Expires = now.AddMinutes(10).UtcDateTime,
SigningCredentials = new(new RsaSecurityKey(rsa), SecurityAlgorithms.RsaSha256)
};

return new JsonWebTokenHandler().CreateToken(tokenDescriptor);
}

HttpClient BuildHttpClient(string token)
{
var client = new HttpClient();
client.DefaultRequestHeaders.Add("Accept", "application/vnd.github+json");
client.DefaultRequestHeaders.Add("X-GitHub-Api-Version", "2022-11-28");
client.DefaultRequestHeaders.Add("User-Agent", "csharp-dummy-app-example");
client.DefaultRequestHeaders.Add("Authorization", $"Bearer {jwtToken}");

return client;

}

async Task<string?> GetAppToken(HttpClient client)
{
var resp = await client.PostAsync($"https://api.github.com/app/installations/{installationId}/access_tokens", null);
var body = await resp.Content.ReadAsStringAsync();

if (!resp.IsSuccessStatusCode)
{
throw new Exception($"Error: {resp.StatusCode}, Body: {body}");
}

var respBody = JsonSerializer.Deserialize<Dictionary<string, object>>(body);
var tokenElement = (JsonElement?)respBody?["token"];
return tokenElement?.ValueKind == JsonValueKind.String ? tokenElement?.GetString() : "";
}
24 changes: 24 additions & 0 deletions cli/cli.csproj
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net8.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
<PropertyGroup>
<RestoreSources>$(RestoreSources);../src/bin/Debug;https://api.nuget.org/v3/index.json</RestoreSources>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.IdentityModel.JsonWebTokens" Version="7.5.1" />
<PackageReference Include="Microsoft.Kiota.Abstractions" Version="1.8.4" />
<PackageReference Include="Microsoft.Kiota.Http.HttpClientLibrary" Version="1.4.0" />
<PackageReference Include="Microsoft.Kiota.Serialization.Form" Version="1.1.6" />
<PackageReference Include="Microsoft.Kiota.Serialization.Json" Version="1.2.3" />
<PackageReference Include="Microsoft.Kiota.Serialization.Multipart" Version="1.1.4" />
<PackageReference Include="Microsoft.Kiota.Serialization.Text" Version="1.1.5" />
<PackageReference Include="Microsoft.Kiota.Authentication.Azure" Version="1.1.5" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="../src/GitHub.Octokit.SDK.csproj" />
</ItemGroup>
</Project>
10 changes: 1 addition & 9 deletions src/Authentication/TokenAuthenticationProvider.cs
Original file line number Diff line number Diff line change
Expand Up @@ -18,11 +18,6 @@ public class TokenAuthenticationProvider : IAuthenticationProvider
/// </summary>
public IAuthenticationProvider TokenAuthProvider => this;

/// <summary>
/// Gets or sets the client identifier.
/// </summary>
public string ClientId { get; set; }

/// <summary>
/// Gets or sets the token.
/// </summary>
Expand All @@ -34,12 +29,9 @@ public class TokenAuthenticationProvider : IAuthenticationProvider
/// <param name="clientId">The client identifier to use.</param>
/// <param name="token">The token to use.</param>
/// <exception cref="ArgumentNullException"></exception>
public TokenAuthenticationProvider(string clientId, string token)
public TokenAuthenticationProvider(string token)
{
ArgumentException.ThrowIfNullOrEmpty(clientId);
ArgumentException.ThrowIfNullOrEmpty(token);

ClientId = clientId;
Token = token;
}

Expand Down
165 changes: 165 additions & 0 deletions src/Client/ClientBuilder.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,165 @@
using GitHub.Octokit.Authentication;

namespace GitHub.Octokit.Client;

public struct ClientOptions
{
public string UserAgent { get; set; }
public string APIVersion { get; set; }
public string BaseURL { get; set; }

public IList<HttpMessageHandler> Middlewares { get; set; }

// PersonalAccessToken should be left blank if GitHub App auth or an unauthenticated client is desired
public string PersonalAccessToken { get; set; }

// GitHubAppPemFilePath should be left blank if token auth or an unauthenticated client is desired.
public string GitHubAppPemFilePath { get; set; }

// GitHubAppID should be left blank if token auth or an unauthenticated client is desired.
public long GitHubAppID { get; set; }

// GitHubAppInstallationID should be left blank if token auth or an unauthenticated client is desired.
public long GitHubAppInstallationID { get; set; }

}

public class ClientBuilder
{
private ClientOptions _options;

public ClientBuilder(ClientOptions? options = null)
{
if (options != null)
{
_options = (ClientOptions)options;
}
else
{
// it's a little annoying that the defaults aren't applied if the user brings their own
// options struct. we can recommend users call GetDefaultClientOptions() before applying
// their own, but that's still not ideal.
_options = GetDefaultClientOptions();
}
}

public ClientOptions GetDefaultClientOptions()
{
return new ClientOptions
{
// TODO(kfcampbell): get version from assembly or tagged release
UserAgent = "octokit/dotnet-sdk@prerelease",
APIVersion = "2022-11-28",
};
}

public ClientBuilder WithUserAgent(string userAgent)
{
_options.UserAgent = userAgent;
return this;
}

public ClientBuilder WithAPIVersion(string apiVersion)
{
_options.APIVersion = apiVersion;
return this;
}

public ClientBuilder WithBaseURL(string baseURL)
{
_options.BaseURL = baseURL;
return this;
}

public ClientBuilder WithTokenAuth(string personalAccessToken)
{
_options.PersonalAccessToken = personalAccessToken;
return this;
}

public ClientBuilder WithGitHubAppAuth(string gitHubAppPemFilePath, long gitHubAppID, long gitHubAppInstallationID)
{
_options.GitHubAppPemFilePath = gitHubAppPemFilePath;
_options.GitHubAppID = gitHubAppID;
_options.GitHubAppInstallationID = gitHubAppInstallationID;
return this;
}

public ClientBuilder WithMiddleware(HttpMessageHandler handler)
{
if (_options.Middlewares == null)
{
_options.Middlewares = new List<HttpMessageHandler>();
}
if (handler != null && !_options.Middlewares.Contains(handler))
{
_options.Middlewares.Add(handler);
}
return this;
}

public GitHubClient Build()
{
// TODO(kfcampbell): figure out how to chain together options here to implement the build method
// this is the crux of the thing, really
// can assume we have some default options set based on the constructor

var authType = GetAuthType();
var token = "";
if (authType == AuthType.PersonalAccessToken)
{
// build a token auth client
token = Environment.GetEnvironmentVariable("GITHUB_TOKEN") ?? "";
}
else if (authType == AuthType.GitHubApp)
{
// build an app auth client
}
else
{
// use an unauthenticated token provider
}

// very basic non-working implementation to keep compiler happy
var requestAdapter = RequestAdapter.Create(new TokenAuthenticationProvider(token), null);
return new GitHubClient(requestAdapter);
}

public enum AuthType
{
PersonalAccessToken,
GitHubApp,
Unauthenticated
}

// TODO(kfcampbell): flesh this out further and test it
// how to differentiate between an unauthenticated client and a client with misconfigured auth?
public AuthType GetAuthType()
{
bool hasToken = !string.IsNullOrEmpty(_options.PersonalAccessToken);
bool hasAppCredentials = !string.IsNullOrEmpty(_options.GitHubAppPemFilePath)
&& _options.GitHubAppID != 0
&& _options.GitHubAppInstallationID != 0;

// Check for valid token authentication setup
if (hasToken && !hasAppCredentials)
{
return AuthType.PersonalAccessToken;
}

// Check for valid GitHub app authentication setup
if (!hasToken && hasAppCredentials)
{
return AuthType.GitHubApp;
}

// Check for unauthenticated setup
if (!hasToken && !hasAppCredentials)
{
return AuthType.Unauthenticated;
}

// If configurations are mixed or incorrect, throw an error
throw new ArgumentException("Invalid authentication configuration.");
}
}
1 change: 1 addition & 0 deletions src/GitHub.Octokit.SDK.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@
</PropertyGroup>

<ItemGroup>
<PackageReference Include="Microsoft.IdentityModel.JsonWebTokens" Version="7.5.1" />
<PackageReference Include="Microsoft.Kiota.Abstractions" Version="1.8.4" />
<PackageReference Include="Microsoft.Kiota.Http.HttpClientLibrary" Version="1.4.0" />
<PackageReference Include="Microsoft.Kiota.Serialization.Form" Version="1.1.6" />
Expand Down
11 changes: 2 additions & 9 deletions test/Authentication/TokenAuthenticationProviderTest.cs
Original file line number Diff line number Diff line change
Expand Up @@ -6,25 +6,18 @@

public class TokenAuthenticationProviderTests
{
private const string ValidClientId = "validClientId";
private const string ValidToken = "validToken";
private TokenAuthenticationProvider _provider;

public TokenAuthenticationProviderTests()
{
_provider = new TokenAuthenticationProvider(ValidClientId, ValidToken);
}

[Fact]
public void Constructor_ThrowsException_WhenClientIdIsEmpty()
{
Assert.Throws<ArgumentException>(() => new TokenAuthenticationProvider("", ValidToken));
_provider = new TokenAuthenticationProvider(ValidToken);
}

[Fact]
public void Constructor_ThrowsException_WhenTokenIsEmpty()
{
Assert.Throws<ArgumentException>(() => new TokenAuthenticationProvider(ValidClientId, ""));
Assert.Throws<ArgumentException>(() => new TokenAuthenticationProvider(""));
}

[Fact]
Expand Down