diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index b6e0d1795..c7a520a61 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -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 diff --git a/.gitignore b/.gitignore index 457a68392..26bf0ef3a 100644 --- a/.gitignore +++ b/.gitignore @@ -108,4 +108,5 @@ node_modules/ testresults/ [Tt]est[Rr]esult*/ **/coverage.json -coverage/* \ No newline at end of file +coverage/* +*.pem \ No newline at end of file diff --git a/GitHub.Octokit.sln b/GitHub.Octokit.sln index e765f3a87..4397c1458 100644 --- a/GitHub.Octokit.sln +++ b/GitHub.Octokit.sln @@ -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 diff --git a/cli/Program.cs b/cli/Program.cs new file mode 100644 index 000000000..7e0d892f4 --- /dev/null +++ b/cli/Program.cs @@ -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 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>(body); + var tokenElement = (JsonElement?)respBody?["token"]; + return tokenElement?.ValueKind == JsonValueKind.String ? tokenElement?.GetString() : ""; +} diff --git a/cli/cli.csproj b/cli/cli.csproj new file mode 100644 index 000000000..0fd38cfb4 --- /dev/null +++ b/cli/cli.csproj @@ -0,0 +1,24 @@ + + + Exe + net8.0 + enable + enable + + + $(RestoreSources);../src/bin/Debug;https://api.nuget.org/v3/index.json + + + + + + + + + + + + + + + diff --git a/src/Authentication/TokenAuthenticationProvider.cs b/src/Authentication/TokenAuthenticationProvider.cs index 614e764bd..ec0ca5c65 100644 --- a/src/Authentication/TokenAuthenticationProvider.cs +++ b/src/Authentication/TokenAuthenticationProvider.cs @@ -18,11 +18,6 @@ public class TokenAuthenticationProvider : IAuthenticationProvider /// public IAuthenticationProvider TokenAuthProvider => this; - /// - /// Gets or sets the client identifier. - /// - public string ClientId { get; set; } - /// /// Gets or sets the token. /// @@ -34,12 +29,9 @@ public class TokenAuthenticationProvider : IAuthenticationProvider /// The client identifier to use. /// The token to use. /// - public TokenAuthenticationProvider(string clientId, string token) + public TokenAuthenticationProvider(string token) { - ArgumentException.ThrowIfNullOrEmpty(clientId); ArgumentException.ThrowIfNullOrEmpty(token); - - ClientId = clientId; Token = token; } diff --git a/src/Client/ClientBuilder.cs b/src/Client/ClientBuilder.cs new file mode 100644 index 000000000..c6f5652e8 --- /dev/null +++ b/src/Client/ClientBuilder.cs @@ -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 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(); + } + 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."); + } +} diff --git a/src/GitHub.Octokit.SDK.csproj b/src/GitHub.Octokit.SDK.csproj index 232d5ed16..41fffdbe8 100644 --- a/src/GitHub.Octokit.SDK.csproj +++ b/src/GitHub.Octokit.SDK.csproj @@ -33,6 +33,7 @@ + diff --git a/test/Authentication/TokenAuthenticationProviderTest.cs b/test/Authentication/TokenAuthenticationProviderTest.cs index 8ede85677..adb8656d3 100644 --- a/test/Authentication/TokenAuthenticationProviderTest.cs +++ b/test/Authentication/TokenAuthenticationProviderTest.cs @@ -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(() => new TokenAuthenticationProvider("", ValidToken)); + _provider = new TokenAuthenticationProvider(ValidToken); } [Fact] public void Constructor_ThrowsException_WhenTokenIsEmpty() { - Assert.Throws(() => new TokenAuthenticationProvider(ValidClientId, "")); + Assert.Throws(() => new TokenAuthenticationProvider("")); } [Fact] diff --git a/test/Client/ClientBuilderTest.cs b/test/Client/ClientBuilderTest.cs new file mode 100644 index 000000000..f58d1a8bf --- /dev/null +++ b/test/Client/ClientBuilderTest.cs @@ -0,0 +1,152 @@ + +using GitHub.Octokit.Client; +using Xunit; + +public class ClientBuilderTests +{ + [Fact] + public void GetAuthType_WithNoAuthDefined_ReturnsUnauthenticated() + { + var options = new ClientOptions(); + var clientBuilder = new ClientBuilder(options); + Assert.Equal(ClientBuilder.AuthType.Unauthenticated, clientBuilder.GetAuthType()); + } + + [Fact] + public void GetAuthType_WithTokenDefined_ReturnsTokenType() + { + var options = new ClientOptions + { + PersonalAccessToken = "some_token", + }; + + var clientBuilder = new ClientBuilder(options); + Assert.Equal(ClientBuilder.AuthType.PersonalAccessToken, clientBuilder.GetAuthType()); + } + + [Fact] + public void GetAuthType_WithAppDefined_ReturnsAppType() + { + var options = new ClientOptions + { + GitHubAppID = 1, + GitHubAppInstallationID = 1, + GitHubAppPemFilePath = "some/path", + }; + + var clientBuilder = new ClientBuilder(options); + Assert.Equal(ClientBuilder.AuthType.GitHubApp, clientBuilder.GetAuthType()); + } + + [Fact] + public void GetAuthType_WithTokenAndAppIdDefined_ReturnsError() + { + var options = new ClientOptions + { + PersonalAccessToken = "some_token", + GitHubAppID = 1, + }; + var clientBuilder = new ClientBuilder(options); + Assert.Throws(() => clientBuilder.GetAuthType()); + } + + [Fact] + public void GetAuthType_WithTokenAndInstallationIdDefined_ReturnsError() + { + var options = new ClientOptions + { + PersonalAccessToken = "some_token", + GitHubAppInstallationID = 1, + }; + var clientBuilder = new ClientBuilder(options); + Assert.Throws(() => clientBuilder.GetAuthType()); + } + + [Fact] + public void GetAuthType_WithTokenAndPemFilePathDefined_ReturnsError() + { + var options = new ClientOptions + { + PersonalAccessToken = "some_token", + GitHubAppPemFilePath = "some/path", + }; + var clientBuilder = new ClientBuilder(options); + Assert.Throws(() => clientBuilder.GetAuthType()); + } + + [Fact] + public void GetAuthType_WithTokenAndAppIdAndPemFilePathDefined_ReturnsError() + { + var options = new ClientOptions + { + PersonalAccessToken = "some_token", + GitHubAppID = 1, + GitHubAppPemFilePath = "some/path", + }; + var clientBuilder = new ClientBuilder(options); + Assert.Throws(() => clientBuilder.GetAuthType()); + } + + [Fact] + public void GetAuthType_WithTokenAndAppIdAndInstallationIdDefined_ReturnsError() + { + var options = new ClientOptions + { + PersonalAccessToken = "some_token", + GitHubAppID = 1, + GitHubAppInstallationID = 1, + }; + var clientBuilder = new ClientBuilder(options); + Assert.Throws(() => clientBuilder.GetAuthType()); + } + + [Fact] + public void GetAuthType_WithTokenAndAppIdAndInstallationIdAndPemFilePathDefined_ReturnsError() + { + var options = new ClientOptions + { + PersonalAccessToken = "some_token", + GitHubAppID = 1, + GitHubAppInstallationID = 1, + GitHubAppPemFilePath = "some/path", + }; + var clientBuilder = new ClientBuilder(options); + Assert.Throws(() => clientBuilder.GetAuthType()); + } + + [Fact] + public void GetAuthType_WithOnlyAppIdAndInstallationIdDefined_ReturnsError() + { + var options = new ClientOptions + { + GitHubAppID = 1, + GitHubAppInstallationID = 1, + }; + var clientBuilder = new ClientBuilder(options); + Assert.Throws(() => clientBuilder.GetAuthType()); + } + + [Fact] + public void GetAuthType_WithOnlyAppIdAndPemFilePathDefined_ReturnsError() + { + var options = new ClientOptions + { + GitHubAppID = 1, + GitHubAppPemFilePath = "some/path", + }; + var clientBuilder = new ClientBuilder(options); + Assert.Throws(() => clientBuilder.GetAuthType()); + } + + [Fact] + public void GetAuthType_WithOnlyInstallationIdAndPemFilePathDefined_ReturnsError() + { + var options = new ClientOptions + { + GitHubAppInstallationID = 1, + GitHubAppPemFilePath = "some/path", + }; + var clientBuilder = new ClientBuilder(options); + Assert.Throws(() => clientBuilder.GetAuthType()); + } +} diff --git a/test/Client/RequestAdaptorTest.cs b/test/Client/RequestAdaptorTest.cs index 2218829d3..6dd1224eb 100644 --- a/test/Client/RequestAdaptorTest.cs +++ b/test/Client/RequestAdaptorTest.cs @@ -9,7 +9,7 @@ public class RequestAdapterTests [Fact] public void Creates_RequestAdaptor_With_Defaults() { - var requestAdapter = RequestAdapter.Create(new TokenAuthenticationProvider("Octokit.Gen", "JRRTOLKIEN")); + var requestAdapter = RequestAdapter.Create(new TokenAuthenticationProvider("JRRTOLKIEN")); Assert.NotNull(requestAdapter); } @@ -17,7 +17,7 @@ public void Creates_RequestAdaptor_With_Defaults() public void Creates_RequestAdaptor_With_GenericHttpClient() { var httpClient = new HttpClient(); - var requestAdapter = RequestAdapter.Create(new TokenAuthenticationProvider("Octokit.Gen", "JRRTOLKIEN"), httpClient); + var requestAdapter = RequestAdapter.Create(new TokenAuthenticationProvider("JRRTOLKIEN"), httpClient); Assert.NotNull(requestAdapter); } @@ -25,7 +25,7 @@ public void Creates_RequestAdaptor_With_GenericHttpClient() public void Creates_RequestAdaptor_With_ClientFactory() { var clientFactory = ClientFactory.Create(); - var requestAdapter = RequestAdapter.Create(new TokenAuthenticationProvider("Octokit.Gen", "JRRTOLKIEN"), clientFactory); + var requestAdapter = RequestAdapter.Create(new TokenAuthenticationProvider("JRRTOLKIEN"), clientFactory); Assert.NotNull(requestAdapter); } }