diff --git a/src/main/java/com/spotify/github/v3/apps/requests/AccessTokenRequest.java b/src/main/java/com/spotify/github/v3/apps/requests/AccessTokenRequest.java new file mode 100644 index 00000000..ede485da --- /dev/null +++ b/src/main/java/com/spotify/github/v3/apps/requests/AccessTokenRequest.java @@ -0,0 +1,53 @@ +/*- + * -\-\- + * github-api + * -- + * Copyright (C) 2016 - 2020 Spotify AB + * -- + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * -/-/- + */ +package com.spotify.github.v3.apps.requests; + +import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.annotation.JsonProperty; +import com.fasterxml.jackson.databind.annotation.JsonDeserialize; +import com.fasterxml.jackson.databind.annotation.JsonSerialize; +import com.spotify.github.GithubStyle; +import java.util.List; +import java.util.Optional; +import org.immutables.value.Value; + +/** Request to create an installation access token with repository scoping. */ +@Value.Immutable +@GithubStyle +@JsonSerialize(as = ImmutableAccessTokenRequest.class) +@JsonDeserialize(as = ImmutableAccessTokenRequest.class) +@JsonInclude(JsonInclude.Include.NON_EMPTY) +public interface AccessTokenRequest { + + /** + * List of repository names that the token should be scoped to. + * + * @return list of repository names + */ + Optional> repositories(); + + /** + * List of repository IDs that the token should be scoped to. + * + * @return list of repository IDs + */ + @JsonProperty("repository_ids") + Optional> repositoryIds(); +} diff --git a/src/main/java/com/spotify/github/v3/clients/GithubAppClient.java b/src/main/java/com/spotify/github/v3/clients/GithubAppClient.java index 3d462ece..aeb96094 100644 --- a/src/main/java/com/spotify/github/v3/clients/GithubAppClient.java +++ b/src/main/java/com/spotify/github/v3/clients/GithubAppClient.java @@ -23,6 +23,7 @@ import com.fasterxml.jackson.core.type.TypeReference; import com.google.common.collect.ImmutableMap; import com.spotify.github.v3.apps.InstallationRepositoriesResponse; +import com.spotify.github.v3.apps.requests.AccessTokenRequest; import com.spotify.github.v3.checks.AccessToken; import com.spotify.github.v3.checks.App; import com.spotify.github.v3.checks.Installation; @@ -155,12 +156,28 @@ public CompletableFuture getUserInstallation() { * Authenticates as an installation * * @return an Installation Token + * @see #getAccessToken(Integer, AccessTokenRequest) for repository-scoped tokens */ public CompletableFuture getAccessToken(final Integer installationId) { final String path = String.format(GET_ACCESS_TOKEN_URL, installationId); return github.post(path, "", AccessToken.class, extraHeaders); } + /** + * Authenticates as an installation with repository scoping. + * + * @param installationId the installation ID + * @param request the access token request with optional repository scoping + * @return an Installation Token + * @see "https://docs.github.com/en/rest/apps/apps#create-an-installation-access-token-for-an-app" + */ + public CompletableFuture getAccessToken( + final Integer installationId, + final AccessTokenRequest request) { + final String path = String.format(GET_ACCESS_TOKEN_URL, installationId); + return github.post(path, github.json().toJsonUnchecked(request), AccessToken.class, extraHeaders); + } + /** * Lists the repositories that an app installation can access. * diff --git a/src/test/java/com/spotify/github/v3/clients/GithubAppClientTest.java b/src/test/java/com/spotify/github/v3/clients/GithubAppClientTest.java index bccb8bc3..c47d3a4d 100644 --- a/src/test/java/com/spotify/github/v3/clients/GithubAppClientTest.java +++ b/src/test/java/com/spotify/github/v3/clients/GithubAppClientTest.java @@ -24,12 +24,16 @@ import static java.nio.charset.Charset.defaultCharset; import static org.hamcrest.MatcherAssert.assertThat; import static org.hamcrest.Matchers.containsInAnyOrder; +import static org.hamcrest.Matchers.containsString; import static org.hamcrest.core.Is.is; import com.fasterxml.jackson.databind.ObjectMapper; import com.google.common.io.Resources; import com.spotify.github.FixtureHelper; import com.spotify.github.v3.apps.InstallationRepositoriesResponse; +import com.spotify.github.v3.apps.requests.AccessTokenRequest; +import com.spotify.github.v3.apps.requests.ImmutableAccessTokenRequest; +import com.spotify.github.v3.checks.AccessToken; import com.spotify.github.v3.checks.App; import com.spotify.github.v3.checks.Installation; import java.io.File; @@ -182,4 +186,68 @@ public void getAuthenticatedApp() throws Exception { recordedRequest.getHeaders().values("Authorization").get(0).startsWith("Bearer "), is(true)); } + + @Test + public void getAccessTokenWithoutScoping() throws Exception { + mockServer.enqueue( + new MockResponse() + .setResponseCode(201) + .setBody(FixtureHelper.loadFixture("githubapp/access-token.json"))); + + AccessToken token = client.getAccessToken(1234).join(); + + assertThat(token.token(), is("ghs_16C7e42F292c6912E7710c838347Ae178B4a")); + + RecordedRequest recordedRequest = mockServer.takeRequest(1, TimeUnit.MILLISECONDS); + assertThat(recordedRequest.getMethod(), is("POST")); + assertThat( + recordedRequest.getRequestUrl().encodedPath(), + is("/app/installations/1234/access_tokens")); + assertThat(recordedRequest.getBody().readUtf8(), is("")); + } + + @Test + public void getAccessTokenWithRepositoryScoping() throws Exception { + mockServer.enqueue( + new MockResponse() + .setResponseCode(201) + .setBody(FixtureHelper.loadFixture("githubapp/access-token.json"))); + + AccessTokenRequest request = ImmutableAccessTokenRequest.builder() + .repositories(List.of("Hello-World")) + .repositoryIds(List.of(1)) + .build(); + + AccessToken token = client.getAccessToken(1234, request).join(); + + assertThat(token.token(), is("ghs_16C7e42F292c6912E7710c838347Ae178B4a")); + + RecordedRequest recordedRequest = mockServer.takeRequest(1, TimeUnit.MILLISECONDS); + assertThat(recordedRequest.getMethod(), is("POST")); + assertThat( + recordedRequest.getRequestUrl().encodedPath(), + is("/app/installations/1234/access_tokens")); + + String requestBody = recordedRequest.getBody().readUtf8(); + assertThat(requestBody, containsString("\"repositories\":[\"Hello-World\"]")); + assertThat(requestBody, containsString("\"repository_ids\":[1]")); + } + + @Test + public void getAccessTokenWithEmptyRequest() throws Exception { + mockServer.enqueue( + new MockResponse() + .setResponseCode(201) + .setBody(FixtureHelper.loadFixture("githubapp/access-token.json"))); + + AccessTokenRequest emptyRequest = ImmutableAccessTokenRequest.builder().build(); + + AccessToken token = client.getAccessToken(1234, emptyRequest).join(); + + assertThat(token.token(), is("ghs_16C7e42F292c6912E7710c838347Ae178B4a")); + + RecordedRequest recordedRequest = mockServer.takeRequest(1, TimeUnit.MILLISECONDS); + String requestBody = recordedRequest.getBody().readUtf8(); + assertThat(requestBody, is("{}")); + } } diff --git a/src/test/resources/com/spotify/github/v3/githubapp/access-token.json b/src/test/resources/com/spotify/github/v3/githubapp/access-token.json new file mode 100644 index 00000000..0085c41a --- /dev/null +++ b/src/test/resources/com/spotify/github/v3/githubapp/access-token.json @@ -0,0 +1,4 @@ +{ + "token": "ghs_16C7e42F292c6912E7710c838347Ae178B4a", + "expires_at": "2024-08-10T05:54:58Z" +}