]> source.dussan.org Git - sonarqube.git/commitdiff
SONAR-22899 Refresh GitHub App Installation token before expiration
authorAurelien Poscia <aurelien.poscia@sonarsource.com>
Wed, 28 Aug 2024 15:27:42 +0000 (17:27 +0200)
committersonartech <sonartech@sonarsource.com>
Thu, 29 Aug 2024 20:02:47 +0000 (20:02 +0000)
15 files changed:
server/sonar-alm-client/src/main/java/org/sonar/alm/client/github/GithubApplicationClientImpl.java
server/sonar-alm-client/src/test/java/org/sonar/alm/client/github/GithubApplicationClientImplTest.java
server/sonar-auth-github/build.gradle
server/sonar-auth-github/src/main/java/org/sonar/auth/github/AppInstallationToken.java
server/sonar-auth-github/src/main/java/org/sonar/auth/github/AppInstallationTokenGenerator.java [new file with mode: 0644]
server/sonar-auth-github/src/main/java/org/sonar/auth/github/AutoRefreshableAppToken.java [new file with mode: 0644]
server/sonar-auth-github/src/main/java/org/sonar/auth/github/ExpiringAppInstallationToken.java [new file with mode: 0644]
server/sonar-auth-github/src/main/java/org/sonar/auth/github/GithubBinding.java
server/sonar-auth-github/src/main/java/org/sonar/auth/github/client/GithubApplicationClient.java
server/sonar-auth-github/src/test/java/org/sonar/auth/github/AppInstallationTokenGeneratorTest.java [new file with mode: 0644]
server/sonar-auth-github/src/test/java/org/sonar/auth/github/AppInstallationTokenTest.java [deleted file]
server/sonar-auth-github/src/test/java/org/sonar/auth/github/AutoRefreshableAppTokenTest.java [new file with mode: 0644]
server/sonar-auth-github/src/test/java/org/sonar/auth/github/ExpiringAppInstallationTokenTest.java [new file with mode: 0644]
server/sonar-webserver-common/src/test/java/org/sonar/server/common/almsettings/github/GithubProjectCreatorFactoryTest.java
server/sonar-webserver-webapi/src/it/java/org/sonar/server/almintegration/ws/github/ImportGithubProjectActionIT.java

index e26807219a134e2e80b94062f128eb39f04524a3..f78a847873464e8736f4e0d37ec44300e1a652cf 100644 (file)
@@ -23,6 +23,7 @@ import com.google.gson.Gson;
 import com.google.gson.reflect.TypeToken;
 import java.io.IOException;
 import java.net.URI;
+import java.time.Clock;
 import java.util.Arrays;
 import java.util.HashMap;
 import java.util.List;
@@ -34,6 +35,7 @@ import java.util.Set;
 import java.util.function.Function;
 import java.util.stream.Collectors;
 import javax.annotation.Nullable;
+import org.apache.commons.lang3.StringUtils;
 import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
 import org.sonar.alm.client.ApplicationHttpClient;
@@ -41,8 +43,7 @@ import org.sonar.alm.client.ApplicationHttpClient.GetResponse;
 import org.sonar.alm.client.github.security.AppToken;
 import org.sonar.alm.client.github.security.GithubAppSecurity;
 import org.sonar.alm.client.gitlab.GsonApp;
-import org.apache.commons.lang3.StringUtils;
-import org.sonar.auth.github.AppInstallationToken;
+import org.sonar.auth.github.ExpiringAppInstallationToken;
 import org.sonar.auth.github.GitHubSettings;
 import org.sonar.auth.github.GithubAppConfiguration;
 import org.sonar.auth.github.GithubAppInstallation;
@@ -78,13 +79,15 @@ public class GithubApplicationClientImpl implements GithubApplicationClient {
   };
   private static final TypeToken<List<GithubBinding.GsonInstallation>> ORGANIZATION_LIST_TYPE = new TypeToken<>() {
   };
+  private final Clock clock;
   protected final GithubApplicationHttpClient githubApplicationHttpClient;
   protected final GithubAppSecurity appSecurity;
   private final GitHubSettings gitHubSettings;
   private final GithubPaginatedHttpClient githubPaginatedHttpClient;
 
-  public GithubApplicationClientImpl(GithubApplicationHttpClient githubApplicationHttpClient, GithubAppSecurity appSecurity, GitHubSettings gitHubSettings,
+  public GithubApplicationClientImpl(Clock clock, GithubApplicationHttpClient githubApplicationHttpClient, GithubAppSecurity appSecurity, GitHubSettings gitHubSettings,
     GithubPaginatedHttpClient githubPaginatedHttpClient) {
+    this.clock = clock;
     this.githubApplicationHttpClient = githubApplicationHttpClient;
     this.appSecurity = appSecurity;
     this.gitHubSettings = gitHubSettings;
@@ -97,13 +100,12 @@ public class GithubApplicationClientImpl implements GithubApplicationClient {
   }
 
   @Override
-  public Optional<AppInstallationToken> createAppInstallationToken(GithubAppConfiguration githubAppConfiguration, long installationId) {
+  public Optional<ExpiringAppInstallationToken> createAppInstallationToken(GithubAppConfiguration githubAppConfiguration, long installationId) {
     AppToken appToken = appSecurity.createAppToken(githubAppConfiguration.getId(), githubAppConfiguration.getPrivateKey());
     String endPoint = "/app/installations/" + installationId + "/access_tokens";
     return post(githubAppConfiguration.getApiEndpoint(), appToken, endPoint, GithubBinding.GsonInstallationToken.class)
-      .map(GithubBinding.GsonInstallationToken::getToken)
-      .filter(Objects::nonNull)
-      .map(AppInstallationToken::new);
+      .filter(token -> token.getToken() != null)
+      .map(appInstallToken -> new ExpiringAppInstallationToken(clock, appInstallToken.getToken(), appInstallToken.getExpiresAt()));
   }
 
   private <T> Optional<T> post(String baseUrl, AccessToken token, String endPoint, Class<T> gsonClass) {
index a0596acb20a8139e63a6e78e2b79d9c214a1d66b..071001b8c01469413cb79f38388d026e9c749a78 100644 (file)
@@ -24,6 +24,9 @@ import com.tngtech.java.junit.dataprovider.DataProviderRunner;
 import com.tngtech.java.junit.dataprovider.UseDataProvider;
 import java.io.IOException;
 import java.nio.charset.StandardCharsets;
+import java.time.Clock;
+import java.time.Instant;
+import java.time.ZoneId;
 import java.util.List;
 import java.util.Optional;
 import java.util.Set;
@@ -43,6 +46,7 @@ import org.sonar.api.testfixtures.log.LogAndArguments;
 import org.sonar.api.testfixtures.log.LogTester;
 import org.sonar.api.utils.log.LoggerLevel;
 import org.sonar.auth.github.AppInstallationToken;
+import org.sonar.auth.github.ExpiringAppInstallationToken;
 import org.sonar.auth.github.GitHubSettings;
 import org.sonar.auth.github.GithubAppConfiguration;
 import org.sonar.auth.github.GithubAppInstallation;
@@ -55,6 +59,7 @@ import org.sonar.auth.github.security.AccessToken;
 import org.sonar.auth.github.security.UserAccessToken;
 import org.sonarqube.ws.client.HttpException;
 
+import static java.lang.String.format;
 import static java.net.HttpURLConnection.HTTP_CREATED;
 import static java.net.HttpURLConnection.HTTP_FORBIDDEN;
 import static java.net.HttpURLConnection.HTTP_NOT_FOUND;
@@ -126,13 +131,13 @@ public class GithubApplicationClientImplTest {
   private AppInstallationToken appInstallationToken = mock();
   private GithubApplicationClient underTest;
 
-
+  private Clock clock = Clock.fixed(Instant.EPOCH, ZoneId.systemDefault());
   private String appUrl = "Any URL";
 
   @Before
   public void setup() {
     when(githubAppConfiguration.getApiEndpoint()).thenReturn(appUrl);
-    underTest = new GithubApplicationClientImpl(githubApplicationHttpClient, appSecurity, gitHubSettings, githubPaginatedHttpClient);
+    underTest = new GithubApplicationClientImpl(clock, githubApplicationHttpClient, appSecurity, gitHubSettings, githubPaginatedHttpClient);
     logTester.clear();
   }
 
@@ -225,13 +230,15 @@ public class GithubApplicationClientImplTest {
   public void checkAppPermissions_IncorrectPermissions() throws IOException {
     AppToken appToken = mockAppToken();
 
-    String json = "{"
-                  + "      \"permissions\": {\n"
-                  + "        \"checks\": \"read\",\n"
-                  + "        \"metadata\": \"read\",\n"
-                  + "        \"pull_requests\": \"read\"\n"
-                  + "      }\n"
-                  + "}";
+    String json = """
+      {
+            "permissions": {
+              "checks": "read",
+              "metadata": "read",
+              "pull_requests": "read"
+            }
+      }
+      """;
 
     when(githubApplicationHttpClient.get(appUrl, appToken, "/app")).thenReturn(new OkGetResponse(json));
 
@@ -244,13 +251,15 @@ public class GithubApplicationClientImplTest {
   public void checkAppPermissions() throws IOException {
     AppToken appToken = mockAppToken();
 
-    String json = "{"
-                  + "      \"permissions\": {\n"
-                  + "        \"checks\": \"write\",\n"
-                  + "        \"metadata\": \"read\",\n"
-                  + "        \"pull_requests\": \"write\"\n"
-                  + "      }\n"
-                  + "}";
+    String json = """
+      {
+            "permissions": {
+              "checks": "write",
+              "metadata": "read",
+              "pull_requests": "write"
+            }
+      }
+      """;
 
     when(githubApplicationHttpClient.get(appUrl, appToken, "/app")).thenReturn(new OkGetResponse(json));
 
@@ -262,12 +271,13 @@ public class GithubApplicationClientImplTest {
     AppToken appToken = new AppToken(APP_JWT_TOKEN);
     when(appSecurity.createAppToken(githubAppConfiguration.getId(), githubAppConfiguration.getPrivateKey())).thenReturn(appToken);
     when(githubApplicationHttpClient.get(appUrl, appToken, "/repos/torvalds/linux/installation"))
-      .thenReturn(new OkGetResponse("{" +
-        "  \"id\": 2," +
-        "  \"account\": {" +
-        "    \"login\": \"torvalds\"" +
-        "  }" +
-        "}"));
+      .thenReturn(new OkGetResponse("""
+        {
+          "id": 2,
+          "account": {
+            "login": "torvalds"
+          }
+        }"""));
 
     assertThat(underTest.getInstallationId(githubAppConfiguration, "torvalds/linux")).hasValue(2L);
   }
@@ -381,7 +391,7 @@ public class GithubApplicationClientImplTest {
     String appUrl = "https://github.sonarsource.com";
     AccessToken accessToken = new UserAccessToken(randomAlphanumeric(10));
 
-    when(githubApplicationHttpClient.get(appUrl, accessToken, String.format("/user/installations?page=%s&per_page=%s", 1, 100)))
+    when(githubApplicationHttpClient.get(appUrl, accessToken, format("/user/installations?page=%s&per_page=%s", 1, 100)))
       .thenThrow(new IOException("OOPS"));
 
     assertThatThrownBy(() -> underTest.listOrganizations(appUrl, accessToken, 1, 100))
@@ -412,11 +422,13 @@ public class GithubApplicationClientImplTest {
   public void listOrganizations_returns_no_installations() throws IOException {
     String appUrl = "https://github.sonarsource.com";
     AccessToken accessToken = new UserAccessToken(randomAlphanumeric(10));
-    String responseJson = "{\n"
-                          + "  \"total_count\": 0\n"
-                          + "} ";
+    String responseJson = """
+      {
+        "total_count": 0
+      }
+      """;
 
-    when(githubApplicationHttpClient.get(appUrl, accessToken, String.format("/user/installations?page=%s&per_page=%s", 1, 100)))
+    when(githubApplicationHttpClient.get(appUrl, accessToken, format("/user/installations?page=%s&per_page=%s", 1, 100)))
       .thenReturn(new OkGetResponse(responseJson));
 
     GithubApplicationClient.Organizations organizations = underTest.listOrganizations(appUrl, accessToken, 1, 100);
@@ -429,85 +441,87 @@ public class GithubApplicationClientImplTest {
   public void listOrganizations_returns_pages_results() throws IOException {
     String appUrl = "https://github.sonarsource.com";
     AccessToken accessToken = new UserAccessToken(randomAlphanumeric(10));
-    String responseJson = "{\n"
-                          + "  \"total_count\": 2,\n"
-                          + "  \"installations\": [\n"
-                          + "    {\n"
-                          + "      \"id\": 1,\n"
-                          + "      \"account\": {\n"
-                          + "        \"login\": \"github\",\n"
-                          + "        \"id\": 1,\n"
-                          + "        \"node_id\": \"MDEyOk9yZ2FuaXphdGlvbjE=\",\n"
-                          + "        \"url\": \"https://github.sonarsource.com/api/v3/orgs/github\",\n"
-                          + "        \"repos_url\": \"https://github.sonarsource.com/api/v3/orgs/github/repos\",\n"
-                          + "        \"events_url\": \"https://github.sonarsource.com/api/v3/orgs/github/events\",\n"
-                          + "        \"hooks_url\": \"https://github.sonarsource.com/api/v3/orgs/github/hooks\",\n"
-                          + "        \"issues_url\": \"https://github.sonarsource.com/api/v3/orgs/github/issues\",\n"
-                          + "        \"members_url\": \"https://github.sonarsource.com/api/v3/orgs/github/members{/member}\",\n"
-                          + "        \"public_members_url\": \"https://github.sonarsource.com/api/v3/orgs/github/public_members{/member}\",\n"
-                          + "        \"avatar_url\": \"https://github.com/images/error/octocat_happy.gif\",\n"
-                          + "        \"description\": \"A great organization\"\n"
-                          + "      },\n"
-                          + "      \"access_tokens_url\": \"https://github.sonarsource.com/api/v3/app/installations/1/access_tokens\",\n"
-                          + "      \"repositories_url\": \"https://github.sonarsource.com/api/v3/installation/repositories\",\n"
-                          + "      \"html_url\": \"https://github.com/organizations/github/settings/installations/1\",\n"
-                          + "      \"app_id\": 1,\n"
-                          + "      \"target_id\": 1,\n"
-                          + "      \"target_type\": \"Organization\",\n"
-                          + "      \"permissions\": {\n"
-                          + "        \"checks\": \"write\",\n"
-                          + "        \"metadata\": \"read\",\n"
-                          + "        \"contents\": \"read\"\n"
-                          + "      },\n"
-                          + "      \"events\": [\n"
-                          + "        \"push\",\n"
-                          + "        \"pull_request\"\n"
-                          + "      ],\n"
-                          + "      \"single_file_name\": \"config.yml\"\n"
-                          + "    },\n"
-                          + "    {\n"
-                          + "      \"id\": 3,\n"
-                          + "      \"account\": {\n"
-                          + "        \"login\": \"octocat\",\n"
-                          + "        \"id\": 2,\n"
-                          + "        \"node_id\": \"MDQ6VXNlcjE=\",\n"
-                          + "        \"avatar_url\": \"https://github.com/images/error/octocat_happy.gif\",\n"
-                          + "        \"gravatar_id\": \"\",\n"
-                          + "        \"url\": \"https://github.sonarsource.com/api/v3/users/octocat\",\n"
-                          + "        \"html_url\": \"https://github.com/octocat\",\n"
-                          + "        \"followers_url\": \"https://github.sonarsource.com/api/v3/users/octocat/followers\",\n"
-                          + "        \"following_url\": \"https://github.sonarsource.com/api/v3/users/octocat/following{/other_user}\",\n"
-                          + "        \"gists_url\": \"https://github.sonarsource.com/api/v3/users/octocat/gists{/gist_id}\",\n"
-                          + "        \"starred_url\": \"https://github.sonarsource.com/api/v3/users/octocat/starred{/owner}{/repo}\",\n"
-                          + "        \"subscriptions_url\": \"https://github.sonarsource.com/api/v3/users/octocat/subscriptions\",\n"
-                          + "        \"organizations_url\": \"https://github.sonarsource.com/api/v3/users/octocat/orgs\",\n"
-                          + "        \"repos_url\": \"https://github.sonarsource.com/api/v3/users/octocat/repos\",\n"
-                          + "        \"events_url\": \"https://github.sonarsource.com/api/v3/users/octocat/events{/privacy}\",\n"
-                          + "        \"received_events_url\": \"https://github.sonarsource.com/api/v3/users/octocat/received_events\",\n"
-                          + "        \"type\": \"User\",\n"
-                          + "        \"site_admin\": false\n"
-                          + "      },\n"
-                          + "      \"access_tokens_url\": \"https://github.sonarsource.com/api/v3/app/installations/1/access_tokens\",\n"
-                          + "      \"repositories_url\": \"https://github.sonarsource.com/api/v3/installation/repositories\",\n"
-                          + "      \"html_url\": \"https://github.com/organizations/github/settings/installations/1\",\n"
-                          + "      \"app_id\": 1,\n"
-                          + "      \"target_id\": 1,\n"
-                          + "      \"target_type\": \"Organization\",\n"
-                          + "      \"permissions\": {\n"
-                          + "        \"checks\": \"write\",\n"
-                          + "        \"metadata\": \"read\",\n"
-                          + "        \"contents\": \"read\"\n"
-                          + "      },\n"
-                          + "      \"events\": [\n"
-                          + "        \"push\",\n"
-                          + "        \"pull_request\"\n"
-                          + "      ],\n"
-                          + "      \"single_file_name\": \"config.yml\"\n"
-                          + "    }\n"
-                          + "  ]\n"
-                          + "} ";
-
-    when(githubApplicationHttpClient.get(appUrl, accessToken, String.format("/user/installations?page=%s&per_page=%s", 1, 100)))
+    String responseJson = """
+      {
+        "total_count": 2,
+        "installations": [
+          {
+            "id": 1,
+            "account": {
+              "login": "github",
+              "id": 1,
+              "node_id": "MDEyOk9yZ2FuaXphdGlvbjE=",
+              "url": "https://github.sonarsource.com/api/v3/orgs/github",
+              "repos_url": "https://github.sonarsource.com/api/v3/orgs/github/repos",
+              "events_url": "https://github.sonarsource.com/api/v3/orgs/github/events",
+              "hooks_url": "https://github.sonarsource.com/api/v3/orgs/github/hooks",
+              "issues_url": "https://github.sonarsource.com/api/v3/orgs/github/issues",
+              "members_url": "https://github.sonarsource.com/api/v3/orgs/github/members{/member}",
+              "public_members_url": "https://github.sonarsource.com/api/v3/orgs/github/public_members{/member}",
+              "avatar_url": "https://github.com/images/error/octocat_happy.gif",
+              "description": "A great organization"
+            },
+            "access_tokens_url": "https://github.sonarsource.com/api/v3/app/installations/1/access_tokens",
+            "repositories_url": "https://github.sonarsource.com/api/v3/installation/repositories",
+            "html_url": "https://github.com/organizations/github/settings/installations/1",
+            "app_id": 1,
+            "target_id": 1,
+            "target_type": "Organization",
+            "permissions": {
+              "checks": "write",
+              "metadata": "read",
+              "contents": "read"
+            },
+            "events": [
+              "push",
+              "pull_request"
+            ],
+            "single_file_name": "config.yml"
+          },
+          {
+            "id": 3,
+            "account": {
+              "login": "octocat",
+              "id": 2,
+              "node_id": "MDQ6VXNlcjE=",
+              "avatar_url": "https://github.com/images/error/octocat_happy.gif",
+              "gravatar_id": "",
+              "url": "https://github.sonarsource.com/api/v3/users/octocat",
+              "html_url": "https://github.com/octocat",
+              "followers_url": "https://github.sonarsource.com/api/v3/users/octocat/followers",
+              "following_url": "https://github.sonarsource.com/api/v3/users/octocat/following{/other_user}",
+              "gists_url": "https://github.sonarsource.com/api/v3/users/octocat/gists{/gist_id}",
+              "starred_url": "https://github.sonarsource.com/api/v3/users/octocat/starred{/owner}{/repo}",
+              "subscriptions_url": "https://github.sonarsource.com/api/v3/users/octocat/subscriptions",
+              "organizations_url": "https://github.sonarsource.com/api/v3/users/octocat/orgs",
+              "repos_url": "https://github.sonarsource.com/api/v3/users/octocat/repos",
+              "events_url": "https://github.sonarsource.com/api/v3/users/octocat/events{/privacy}",
+              "received_events_url": "https://github.sonarsource.com/api/v3/users/octocat/received_events",
+              "type": "User",
+              "site_admin": false
+            },
+            "access_tokens_url": "https://github.sonarsource.com/api/v3/app/installations/1/access_tokens",
+            "repositories_url": "https://github.sonarsource.com/api/v3/installation/repositories",
+            "html_url": "https://github.com/organizations/github/settings/installations/1",
+            "app_id": 1,
+            "target_id": 1,
+            "target_type": "Organization",
+            "permissions": {
+              "checks": "write",
+              "metadata": "read",
+              "contents": "read"
+            },
+            "events": [
+              "push",
+              "pull_request"
+            ],
+            "single_file_name": "config.yml"
+          }
+        ]
+      }
+      """;
+
+    when(githubApplicationHttpClient.get(appUrl, accessToken, format("/user/installations?page=%s&per_page=%s", 1, 100)))
       .thenReturn(new OkGetResponse(responseJson));
 
     GithubApplicationClient.Organizations organizations = underTest.listOrganizations(appUrl, accessToken, 1, 100);
@@ -599,7 +613,7 @@ public class GithubApplicationClientImplTest {
     String appUrl = "https://github.sonarsource.com";
     AccessToken accessToken = new UserAccessToken(randomAlphanumeric(10));
 
-    when(githubApplicationHttpClient.get(appUrl, accessToken, String.format("/search/repositories?q=%s&page=%s&per_page=%s", "org:test", 1, 100)))
+    when(githubApplicationHttpClient.get(appUrl, accessToken, format("/search/repositories?q=%s&page=%s&per_page=%s", "org:test", 1, 100)))
       .thenThrow(new IOException("OOPS"));
 
     assertThatThrownBy(() -> underTest.listRepositories(appUrl, accessToken, "test", null, 1, 100))
@@ -631,10 +645,10 @@ public class GithubApplicationClientImplTest {
     String appUrl = "https://github.sonarsource.com";
     AccessToken accessToken = new UserAccessToken(randomAlphanumeric(10));
     String responseJson = "{\n"
-                          + "  \"total_count\": 0\n"
-                          + "}";
+      + "  \"total_count\": 0\n"
+      + "}";
 
-    when(githubApplicationHttpClient.get(appUrl, accessToken, String.format("/search/repositories?q=%s&page=%s&per_page=%s", "fork:true+org:github", 1, 100)))
+    when(githubApplicationHttpClient.get(appUrl, accessToken, format("/search/repositories?q=%s&page=%s&per_page=%s", "fork:true+org:github", 1, 100)))
       .thenReturn(new OkGetResponse(responseJson));
 
     GithubApplicationClient.Repositories repositories = underTest.listRepositories(appUrl, accessToken, "github", null, 1, 100);
@@ -647,82 +661,83 @@ public class GithubApplicationClientImplTest {
   public void listRepositories_returns_pages_results() throws IOException {
     String appUrl = "https://github.sonarsource.com";
     AccessToken accessToken = new UserAccessToken(randomAlphanumeric(10));
-    String responseJson = "{\n"
-                          + "  \"total_count\": 2,\n"
-                          + "  \"incomplete_results\": false,\n"
-                          + "  \"items\": [\n"
-                          + "    {\n"
-                          + "      \"id\": 3081286,\n"
-                          + "      \"node_id\": \"MDEwOlJlcG9zaXRvcnkzMDgxMjg2\",\n"
-                          + "      \"name\": \"HelloWorld\",\n"
-                          + "      \"full_name\": \"github/HelloWorld\",\n"
-                          + "      \"owner\": {\n"
-                          + "        \"login\": \"github\",\n"
-                          + "        \"id\": 872147,\n"
-                          + "        \"node_id\": \"MDQ6VXNlcjg3MjE0Nw==\",\n"
-                          + "        \"avatar_url\": \"https://github.sonarsource.com/images/error/octocat_happy.gif\",\n"
-                          + "        \"gravatar_id\": \"\",\n"
-                          + "        \"url\": \"https://github.sonarsource.com/api/v3/users/github\",\n"
-                          + "        \"received_events_url\": \"https://github.sonarsource.com/api/v3/users/github/received_events\",\n"
-                          + "        \"type\": \"User\"\n"
-                          + "      },\n"
-                          + "      \"private\": false,\n"
-                          + "      \"html_url\": \"https://github.com/github/HelloWorld\",\n"
-                          + "      \"description\": \"A C implementation of HelloWorld\",\n"
-                          + "      \"fork\": false,\n"
-                          + "      \"url\": \"https://github.sonarsource.com/api/v3/repos/github/HelloWorld\",\n"
-                          + "      \"created_at\": \"2012-01-01T00:31:50Z\",\n"
-                          + "      \"updated_at\": \"2013-01-05T17:58:47Z\",\n"
-                          + "      \"pushed_at\": \"2012-01-01T00:37:02Z\",\n"
-                          + "      \"homepage\": \"\",\n"
-                          + "      \"size\": 524,\n"
-                          + "      \"stargazers_count\": 1,\n"
-                          + "      \"watchers_count\": 1,\n"
-                          + "      \"language\": \"Assembly\",\n"
-                          + "      \"forks_count\": 0,\n"
-                          + "      \"open_issues_count\": 0,\n"
-                          + "      \"master_branch\": \"master\",\n"
-                          + "      \"default_branch\": \"master\",\n"
-                          + "      \"score\": 1.0\n"
-                          + "    },\n"
-                          + "    {\n"
-                          + "      \"id\": 3081286,\n"
-                          + "      \"node_id\": \"MDEwOlJlcG9zaXRvcnkzMDgxMjg2\",\n"
-                          + "      \"name\": \"HelloUniverse\",\n"
-                          + "      \"full_name\": \"github/HelloUniverse\",\n"
-                          + "      \"owner\": {\n"
-                          + "        \"login\": \"github\",\n"
-                          + "        \"id\": 872147,\n"
-                          + "        \"node_id\": \"MDQ6VXNlcjg3MjE0Nw==\",\n"
-                          + "        \"avatar_url\": \"https://github.sonarsource.com/images/error/octocat_happy.gif\",\n"
-                          + "        \"gravatar_id\": \"\",\n"
-                          + "        \"url\": \"https://github.sonarsource.com/api/v3/users/github\",\n"
-                          + "        \"received_events_url\": \"https://github.sonarsource.com/api/v3/users/github/received_events\",\n"
-                          + "        \"type\": \"User\"\n"
-                          + "      },\n"
-                          + "      \"private\": false,\n"
-                          + "      \"html_url\": \"https://github.com/github/HelloUniverse\",\n"
-                          + "      \"description\": \"A C implementation of HelloUniverse\",\n"
-                          + "      \"fork\": false,\n"
-                          + "      \"url\": \"https://github.sonarsource.com/api/v3/repos/github/HelloUniverse\",\n"
-                          + "      \"created_at\": \"2012-01-01T00:31:50Z\",\n"
-                          + "      \"updated_at\": \"2013-01-05T17:58:47Z\",\n"
-                          + "      \"pushed_at\": \"2012-01-01T00:37:02Z\",\n"
-                          + "      \"homepage\": \"\",\n"
-                          + "      \"size\": 524,\n"
-                          + "      \"stargazers_count\": 1,\n"
-                          + "      \"watchers_count\": 1,\n"
-                          + "      \"language\": \"Assembly\",\n"
-                          + "      \"forks_count\": 0,\n"
-                          + "      \"open_issues_count\": 0,\n"
-                          + "      \"master_branch\": \"master\",\n"
-                          + "      \"default_branch\": \"master\",\n"
-                          + "      \"score\": 1.0\n"
-                          + "    }\n"
-                          + "  ]\n"
-                          + "}";
-
-    when(githubApplicationHttpClient.get(appUrl, accessToken, String.format("/search/repositories?q=%s&page=%s&per_page=%s", "fork:true+org:github", 1, 100)))
+    String responseJson = """
+      {
+        "total_count": 2,
+        "incomplete_results": false,
+        "items": [
+          {
+            "id": 3081286,
+            "node_id": "MDEwOlJlcG9zaXRvcnkzMDgxMjg2",
+            "name": "HelloWorld",
+            "full_name": "github/HelloWorld",
+            "owner": {
+              "login": "github",
+              "id": 872147,
+              "node_id": "MDQ6VXNlcjg3MjE0Nw==",
+              "avatar_url": "https://github.sonarsource.com/images/error/octocat_happy.gif",
+              "gravatar_id": "",
+              "url": "https://github.sonarsource.com/api/v3/users/github",
+              "received_events_url": "https://github.sonarsource.com/api/v3/users/github/received_events",
+              "type": "User"
+            },
+            "private": false,
+            "html_url": "https://github.com/github/HelloWorld",
+            "description": "A C implementation of HelloWorld",
+            "fork": false,
+            "url": "https://github.sonarsource.com/api/v3/repos/github/HelloWorld",
+            "created_at": "2012-01-01T00:31:50Z",
+            "updated_at": "2013-01-05T17:58:47Z",
+            "pushed_at": "2012-01-01T00:37:02Z",
+            "homepage": "",
+            "size": 524,
+            "stargazers_count": 1,
+            "watchers_count": 1,
+            "language": "Assembly",
+            "forks_count": 0,
+            "open_issues_count": 0,
+            "master_branch": "master",
+            "default_branch": "master",
+            "score": 1.0
+          },
+          {
+            "id": 3081286,
+            "node_id": "MDEwOlJlcG9zaXRvcnkzMDgxMjg2",
+            "name": "HelloUniverse",
+            "full_name": "github/HelloUniverse",
+            "owner": {
+              "login": "github",
+              "id": 872147,
+              "node_id": "MDQ6VXNlcjg3MjE0Nw==",
+              "avatar_url": "https://github.sonarsource.com/images/error/octocat_happy.gif",
+              "gravatar_id": "",
+              "url": "https://github.sonarsource.com/api/v3/users/github",
+              "received_events_url": "https://github.sonarsource.com/api/v3/users/github/received_events",
+              "type": "User"
+            },
+            "private": false,
+            "html_url": "https://github.com/github/HelloUniverse",
+            "description": "A C implementation of HelloUniverse",
+            "fork": false,
+            "url": "https://github.sonarsource.com/api/v3/repos/github/HelloUniverse",
+            "created_at": "2012-01-01T00:31:50Z",
+            "updated_at": "2013-01-05T17:58:47Z",
+            "pushed_at": "2012-01-01T00:37:02Z",
+            "homepage": "",
+            "size": 524,
+            "stargazers_count": 1,
+            "watchers_count": 1,
+            "language": "Assembly",
+            "forks_count": 0,
+            "open_issues_count": 0,
+            "master_branch": "master",
+            "default_branch": "master",
+            "score": 1.0
+          }
+        ]
+      }""";
+
+    when(githubApplicationHttpClient.get(appUrl, accessToken, format("/search/repositories?q=%s&page=%s&per_page=%s", "fork:true+org:github", 1, 100)))
       .thenReturn(new OkGetResponse(responseJson));
     GithubApplicationClient.Repositories repositories = underTest.listRepositories(appUrl, accessToken, "github", null, 1, 100);
 
@@ -736,48 +751,49 @@ public class GithubApplicationClientImplTest {
   public void listRepositories_returns_search_results() throws IOException {
     String appUrl = "https://github.sonarsource.com";
     AccessToken accessToken = new UserAccessToken(randomAlphanumeric(10));
-    String responseJson = "{\n"
-                          + "  \"total_count\": 2,\n"
-                          + "  \"incomplete_results\": false,\n"
-                          + "  \"items\": [\n"
-                          + "    {\n"
-                          + "      \"id\": 3081286,\n"
-                          + "      \"node_id\": \"MDEwOlJlcG9zaXRvcnkzMDgxMjg2\",\n"
-                          + "      \"name\": \"HelloWorld\",\n"
-                          + "      \"full_name\": \"github/HelloWorld\",\n"
-                          + "      \"owner\": {\n"
-                          + "        \"login\": \"github\",\n"
-                          + "        \"id\": 872147,\n"
-                          + "        \"node_id\": \"MDQ6VXNlcjg3MjE0Nw==\",\n"
-                          + "        \"avatar_url\": \"https://github.sonarsource.com/images/error/octocat_happy.gif\",\n"
-                          + "        \"gravatar_id\": \"\",\n"
-                          + "        \"url\": \"https://github.sonarsource.com/api/v3/users/github\",\n"
-                          + "        \"received_events_url\": \"https://github.sonarsource.com/api/v3/users/github/received_events\",\n"
-                          + "        \"type\": \"User\"\n"
-                          + "      },\n"
-                          + "      \"private\": false,\n"
-                          + "      \"html_url\": \"https://github.com/github/HelloWorld\",\n"
-                          + "      \"description\": \"A C implementation of HelloWorld\",\n"
-                          + "      \"fork\": false,\n"
-                          + "      \"url\": \"https://github.sonarsource.com/api/v3/repos/github/HelloWorld\",\n"
-                          + "      \"created_at\": \"2012-01-01T00:31:50Z\",\n"
-                          + "      \"updated_at\": \"2013-01-05T17:58:47Z\",\n"
-                          + "      \"pushed_at\": \"2012-01-01T00:37:02Z\",\n"
-                          + "      \"homepage\": \"\",\n"
-                          + "      \"size\": 524,\n"
-                          + "      \"stargazers_count\": 1,\n"
-                          + "      \"watchers_count\": 1,\n"
-                          + "      \"language\": \"Assembly\",\n"
-                          + "      \"forks_count\": 0,\n"
-                          + "      \"open_issues_count\": 0,\n"
-                          + "      \"master_branch\": \"master\",\n"
-                          + "      \"default_branch\": \"master\",\n"
-                          + "      \"score\": 1.0\n"
-                          + "    }\n"
-                          + "  ]\n"
-                          + "}";
-
-    when(githubApplicationHttpClient.get(appUrl, accessToken, String.format("/search/repositories?q=%s&page=%s&per_page=%s", "world+fork:true+org:github", 1, 100)))
+    String responseJson = """
+      {
+        "total_count": 2,
+        "incomplete_results": false,
+        "items": [
+          {
+            "id": 3081286,
+            "node_id": "MDEwOlJlcG9zaXRvcnkzMDgxMjg2",
+            "name": "HelloWorld",
+            "full_name": "github/HelloWorld",
+            "owner": {
+              "login": "github",
+              "id": 872147,
+              "node_id": "MDQ6VXNlcjg3MjE0Nw==",
+              "avatar_url": "https://github.sonarsource.com/images/error/octocat_happy.gif",
+              "gravatar_id": "",
+              "url": "https://github.sonarsource.com/api/v3/users/github",
+              "received_events_url": "https://github.sonarsource.com/api/v3/users/github/received_events",
+              "type": "User"
+            },
+            "private": false,
+            "html_url": "https://github.com/github/HelloWorld",
+            "description": "A C implementation of HelloWorld",
+            "fork": false,
+            "url": "https://github.sonarsource.com/api/v3/repos/github/HelloWorld",
+            "created_at": "2012-01-01T00:31:50Z",
+            "updated_at": "2013-01-05T17:58:47Z",
+            "pushed_at": "2012-01-01T00:37:02Z",
+            "homepage": "",
+            "size": 524,
+            "stargazers_count": 1,
+            "watchers_count": 1,
+            "language": "Assembly",
+            "forks_count": 0,
+            "open_issues_count": 0,
+            "master_branch": "master",
+            "default_branch": "master",
+            "score": 1.0
+          }
+        ]
+      }""";
+
+    when(githubApplicationHttpClient.get(appUrl, accessToken, format("/search/repositories?q=%s&page=%s&per_page=%s", "world+fork:true+org:github", 1, 100)))
       .thenReturn(new GetResponse() {
         @Override
         public Optional<String> getNextEndPoint() {
@@ -836,142 +852,142 @@ public class GithubApplicationClientImplTest {
     String appUrl = "https://github.sonarsource.com";
     AccessToken accessToken = new UserAccessToken(randomAlphanumeric(10));
     String responseJson = "{\n"
-                          + "  \"id\": 1296269,\n"
-                          + "  \"node_id\": \"MDEwOlJlcG9zaXRvcnkxMjk2MjY5\",\n"
-                          + "  \"name\": \"Hello-World\",\n"
-                          + "  \"full_name\": \"octocat/Hello-World\",\n"
-                          + "  \"owner\": {\n"
-                          + "    \"login\": \"octocat\",\n"
-                          + "    \"id\": 1,\n"
-                          + "    \"node_id\": \"MDQ6VXNlcjE=\",\n"
-                          + "    \"avatar_url\": \"https://github.sonarsource.com/images/error/octocat_happy.gif\",\n"
-                          + "    \"gravatar_id\": \"\",\n"
-                          + "    \"url\": \"https://github.sonarsource.com/api/v3/users/octocat\",\n"
-                          + "    \"html_url\": \"https://github.com/octocat\",\n"
-                          + "    \"followers_url\": \"https://github.sonarsource.com/api/v3/users/octocat/followers\",\n"
-                          + "    \"following_url\": \"https://github.sonarsource.com/api/v3/users/octocat/following{/other_user}\",\n"
-                          + "    \"gists_url\": \"https://github.sonarsource.com/api/v3/users/octocat/gists{/gist_id}\",\n"
-                          + "    \"starred_url\": \"https://github.sonarsource.com/api/v3/users/octocat/starred{/owner}{/repo}\",\n"
-                          + "    \"subscriptions_url\": \"https://github.sonarsource.com/api/v3/users/octocat/subscriptions\",\n"
-                          + "    \"organizations_url\": \"https://github.sonarsource.com/api/v3/users/octocat/orgs\",\n"
-                          + "    \"repos_url\": \"https://github.sonarsource.com/api/v3/users/octocat/repos\",\n"
-                          + "    \"events_url\": \"https://github.sonarsource.com/api/v3/users/octocat/events{/privacy}\",\n"
-                          + "    \"received_events_url\": \"https://github.sonarsource.com/api/v3/users/octocat/received_events\",\n"
-                          + "    \"type\": \"User\",\n"
-                          + "    \"site_admin\": false\n"
-                          + "  },\n"
-                          + "  \"private\": false,\n"
-                          + "  \"html_url\": \"https://github.com/octocat/Hello-World\",\n"
-                          + "  \"description\": \"This your first repo!\",\n"
-                          + "  \"fork\": false,\n"
-                          + "  \"url\": \"https://github.sonarsource.com/api/v3/repos/octocat/Hello-World\",\n"
-                          + "  \"archive_url\": \"https://github.sonarsource.com/api/v3/repos/octocat/Hello-World/{archive_format}{/ref}\",\n"
-                          + "  \"assignees_url\": \"https://github.sonarsource.com/api/v3/repos/octocat/Hello-World/assignees{/user}\",\n"
-                          + "  \"blobs_url\": \"https://github.sonarsource.com/api/v3/repos/octocat/Hello-World/git/blobs{/sha}\",\n"
-                          + "  \"branches_url\": \"https://github.sonarsource.com/api/v3/repos/octocat/Hello-World/branches{/branch}\",\n"
-                          + "  \"collaborators_url\": \"https://github.sonarsource.com/api/v3/repos/octocat/Hello-World/collaborators{/collaborator}\",\n"
-                          + "  \"comments_url\": \"https://github.sonarsource.com/api/v3/repos/octocat/Hello-World/comments{/number}\",\n"
-                          + "  \"commits_url\": \"https://github.sonarsource.com/api/v3/repos/octocat/Hello-World/commits{/sha}\",\n"
-                          + "  \"compare_url\": \"https://github.sonarsource.com/api/v3/repos/octocat/Hello-World/compare/{base}...{head}\",\n"
-                          + "  \"contents_url\": \"https://github.sonarsource.com/api/v3/repos/octocat/Hello-World/contents/{+path}\",\n"
-                          + "  \"contributors_url\": \"https://github.sonarsource.com/api/v3/repos/octocat/Hello-World/contributors\",\n"
-                          + "  \"deployments_url\": \"https://github.sonarsource.com/api/v3/repos/octocat/Hello-World/deployments\",\n"
-                          + "  \"downloads_url\": \"https://github.sonarsource.com/api/v3/repos/octocat/Hello-World/downloads\",\n"
-                          + "  \"events_url\": \"https://github.sonarsource.com/api/v3/repos/octocat/Hello-World/events\",\n"
-                          + "  \"forks_url\": \"https://github.sonarsource.com/api/v3/repos/octocat/Hello-World/forks\",\n"
-                          + "  \"git_commits_url\": \"https://github.sonarsource.com/api/v3/repos/octocat/Hello-World/git/commits{/sha}\",\n"
-                          + "  \"git_refs_url\": \"https://github.sonarsource.com/api/v3/repos/octocat/Hello-World/git/refs{/sha}\",\n"
-                          + "  \"git_tags_url\": \"https://github.sonarsource.com/api/v3/repos/octocat/Hello-World/git/tags{/sha}\",\n"
-                          + "  \"git_url\": \"git:github.com/octocat/Hello-World.git\",\n"
-                          + "  \"issue_comment_url\": \"https://github.sonarsource.com/api/v3/repos/octocat/Hello-World/issues/comments{/number}\",\n"
-                          + "  \"issue_events_url\": \"https://github.sonarsource.com/api/v3/repos/octocat/Hello-World/issues/events{/number}\",\n"
-                          + "  \"issues_url\": \"https://github.sonarsource.com/api/v3/repos/octocat/Hello-World/issues{/number}\",\n"
-                          + "  \"keys_url\": \"https://github.sonarsource.com/api/v3/repos/octocat/Hello-World/keys{/key_id}\",\n"
-                          + "  \"labels_url\": \"https://github.sonarsource.com/api/v3/repos/octocat/Hello-World/labels{/name}\",\n"
-                          + "  \"languages_url\": \"https://github.sonarsource.com/api/v3/repos/octocat/Hello-World/languages\",\n"
-                          + "  \"merges_url\": \"https://github.sonarsource.com/api/v3/repos/octocat/Hello-World/merges\",\n"
-                          + "  \"milestones_url\": \"https://github.sonarsource.com/api/v3/repos/octocat/Hello-World/milestones{/number}\",\n"
-                          + "  \"notifications_url\": \"https://github.sonarsource.com/api/v3/repos/octocat/Hello-World/notifications{?since,all,participating}\",\n"
-                          + "  \"pulls_url\": \"https://github.sonarsource.com/api/v3/repos/octocat/Hello-World/pulls{/number}\",\n"
-                          + "  \"releases_url\": \"https://github.sonarsource.com/api/v3/repos/octocat/Hello-World/releases{/id}\",\n"
-                          + "  \"ssh_url\": \"git@github.com:octocat/Hello-World.git\",\n"
-                          + "  \"stargazers_url\": \"https://github.sonarsource.com/api/v3/repos/octocat/Hello-World/stargazers\",\n"
-                          + "  \"statuses_url\": \"https://github.sonarsource.com/api/v3/repos/octocat/Hello-World/statuses/{sha}\",\n"
-                          + "  \"subscribers_url\": \"https://github.sonarsource.com/api/v3/repos/octocat/Hello-World/subscribers\",\n"
-                          + "  \"subscription_url\": \"https://github.sonarsource.com/api/v3/repos/octocat/Hello-World/subscription\",\n"
-                          + "  \"tags_url\": \"https://github.sonarsource.com/api/v3/repos/octocat/Hello-World/tags\",\n"
-                          + "  \"teams_url\": \"https://github.sonarsource.com/api/v3/repos/octocat/Hello-World/teams\",\n"
-                          + "  \"trees_url\": \"https://github.sonarsource.com/api/v3/repos/octocat/Hello-World/git/trees{/sha}\",\n"
-                          + "  \"clone_url\": \"https://github.com/octocat/Hello-World.git\",\n"
-                          + "  \"mirror_url\": \"git:git.example.com/octocat/Hello-World\",\n"
-                          + "  \"hooks_url\": \"https://github.sonarsource.com/api/v3/repos/octocat/Hello-World/hooks\",\n"
-                          + "  \"svn_url\": \"https://svn.github.com/octocat/Hello-World\",\n"
-                          + "  \"homepage\": \"https://github.com\",\n"
-                          + "  \"language\": null,\n"
-                          + "  \"forks_count\": 9,\n"
-                          + "  \"stargazers_count\": 80,\n"
-                          + "  \"watchers_count\": 80,\n"
-                          + "  \"size\": 108,\n"
-                          + "  \"default_branch\": \"master\",\n"
-                          + "  \"open_issues_count\": 0,\n"
-                          + "  \"is_template\": true,\n"
-                          + "  \"topics\": [\n"
-                          + "    \"octocat\",\n"
-                          + "    \"atom\",\n"
-                          + "    \"electron\",\n"
-                          + "    \"api\"\n"
-                          + "  ],\n"
-                          + "  \"has_issues\": true,\n"
-                          + "  \"has_projects\": true,\n"
-                          + "  \"has_wiki\": true,\n"
-                          + "  \"has_pages\": false,\n"
-                          + "  \"has_downloads\": true,\n"
-                          + "  \"archived\": false,\n"
-                          + "  \"disabled\": false,\n"
-                          + "  \"visibility\": \"public\",\n"
-                          + "  \"pushed_at\": \"2011-01-26T19:06:43Z\",\n"
-                          + "  \"created_at\": \"2011-01-26T19:01:12Z\",\n"
-                          + "  \"updated_at\": \"2011-01-26T19:14:43Z\",\n"
-                          + "  \"permissions\": {\n"
-                          + "    \"admin\": false,\n"
-                          + "    \"push\": false,\n"
-                          + "    \"pull\": true\n"
-                          + "  },\n"
-                          + "  \"allow_rebase_merge\": true,\n"
-                          + "  \"template_repository\": null,\n"
-                          + "  \"allow_squash_merge\": true,\n"
-                          + "  \"allow_merge_commit\": true,\n"
-                          + "  \"subscribers_count\": 42,\n"
-                          + "  \"network_count\": 0,\n"
-                          + "  \"anonymous_access_enabled\": false,\n"
-                          + "  \"license\": {\n"
-                          + "    \"key\": \"mit\",\n"
-                          + "    \"name\": \"MIT License\",\n"
-                          + "    \"spdx_id\": \"MIT\",\n"
-                          + "    \"url\": \"https://github.sonarsource.com/api/v3/licenses/mit\",\n"
-                          + "    \"node_id\": \"MDc6TGljZW5zZW1pdA==\"\n"
-                          + "  },\n"
-                          + "  \"organization\": {\n"
-                          + "    \"login\": \"octocat\",\n"
-                          + "    \"id\": 1,\n"
-                          + "    \"node_id\": \"MDQ6VXNlcjE=\",\n"
-                          + "    \"avatar_url\": \"https://github.com/images/error/octocat_happy.gif\",\n"
-                          + "    \"gravatar_id\": \"\",\n"
-                          + "    \"url\": \"https://github.sonarsource.com/api/v3/users/octocat\",\n"
-                          + "    \"html_url\": \"https://github.com/octocat\",\n"
-                          + "    \"followers_url\": \"https://github.sonarsource.com/api/v3/users/octocat/followers\",\n"
-                          + "    \"following_url\": \"https://github.sonarsource.com/api/v3/users/octocat/following{/other_user}\",\n"
-                          + "    \"gists_url\": \"https://github.sonarsource.com/api/v3/users/octocat/gists{/gist_id}\",\n"
-                          + "    \"starred_url\": \"https://github.sonarsource.com/api/v3/users/octocat/starred{/owner}{/repo}\",\n"
-                          + "    \"subscriptions_url\": \"https://github.sonarsource.com/api/v3/users/octocat/subscriptions\",\n"
-                          + "    \"organizations_url\": \"https://github.sonarsource.com/api/v3/users/octocat/orgs\",\n"
-                          + "    \"repos_url\": \"https://github.sonarsource.com/api/v3/users/octocat/repos\",\n"
-                          + "    \"events_url\": \"https://github.sonarsource.com/api/v3/users/octocat/events{/privacy}\",\n"
-                          + "    \"received_events_url\": \"https://github.sonarsource.com/api/v3/users/octocat/received_events\",\n"
-                          + "    \"type\": \"Organization\",\n"
-                          + "    \"site_admin\": false\n"
-                          + "  }"
-                          + "}";
+      + "  \"id\": 1296269,\n"
+      + "  \"node_id\": \"MDEwOlJlcG9zaXRvcnkxMjk2MjY5\",\n"
+      + "  \"name\": \"Hello-World\",\n"
+      + "  \"full_name\": \"octocat/Hello-World\",\n"
+      + "  \"owner\": {\n"
+      + "    \"login\": \"octocat\",\n"
+      + "    \"id\": 1,\n"
+      + "    \"node_id\": \"MDQ6VXNlcjE=\",\n"
+      + "    \"avatar_url\": \"https://github.sonarsource.com/images/error/octocat_happy.gif\",\n"
+      + "    \"gravatar_id\": \"\",\n"
+      + "    \"url\": \"https://github.sonarsource.com/api/v3/users/octocat\",\n"
+      + "    \"html_url\": \"https://github.com/octocat\",\n"
+      + "    \"followers_url\": \"https://github.sonarsource.com/api/v3/users/octocat/followers\",\n"
+      + "    \"following_url\": \"https://github.sonarsource.com/api/v3/users/octocat/following{/other_user}\",\n"
+      + "    \"gists_url\": \"https://github.sonarsource.com/api/v3/users/octocat/gists{/gist_id}\",\n"
+      + "    \"starred_url\": \"https://github.sonarsource.com/api/v3/users/octocat/starred{/owner}{/repo}\",\n"
+      + "    \"subscriptions_url\": \"https://github.sonarsource.com/api/v3/users/octocat/subscriptions\",\n"
+      + "    \"organizations_url\": \"https://github.sonarsource.com/api/v3/users/octocat/orgs\",\n"
+      + "    \"repos_url\": \"https://github.sonarsource.com/api/v3/users/octocat/repos\",\n"
+      + "    \"events_url\": \"https://github.sonarsource.com/api/v3/users/octocat/events{/privacy}\",\n"
+      + "    \"received_events_url\": \"https://github.sonarsource.com/api/v3/users/octocat/received_events\",\n"
+      + "    \"type\": \"User\",\n"
+      + "    \"site_admin\": false\n"
+      + "  },\n"
+      + "  \"private\": false,\n"
+      + "  \"html_url\": \"https://github.com/octocat/Hello-World\",\n"
+      + "  \"description\": \"This your first repo!\",\n"
+      + "  \"fork\": false,\n"
+      + "  \"url\": \"https://github.sonarsource.com/api/v3/repos/octocat/Hello-World\",\n"
+      + "  \"archive_url\": \"https://github.sonarsource.com/api/v3/repos/octocat/Hello-World/{archive_format}{/ref}\",\n"
+      + "  \"assignees_url\": \"https://github.sonarsource.com/api/v3/repos/octocat/Hello-World/assignees{/user}\",\n"
+      + "  \"blobs_url\": \"https://github.sonarsource.com/api/v3/repos/octocat/Hello-World/git/blobs{/sha}\",\n"
+      + "  \"branches_url\": \"https://github.sonarsource.com/api/v3/repos/octocat/Hello-World/branches{/branch}\",\n"
+      + "  \"collaborators_url\": \"https://github.sonarsource.com/api/v3/repos/octocat/Hello-World/collaborators{/collaborator}\",\n"
+      + "  \"comments_url\": \"https://github.sonarsource.com/api/v3/repos/octocat/Hello-World/comments{/number}\",\n"
+      + "  \"commits_url\": \"https://github.sonarsource.com/api/v3/repos/octocat/Hello-World/commits{/sha}\",\n"
+      + "  \"compare_url\": \"https://github.sonarsource.com/api/v3/repos/octocat/Hello-World/compare/{base}...{head}\",\n"
+      + "  \"contents_url\": \"https://github.sonarsource.com/api/v3/repos/octocat/Hello-World/contents/{+path}\",\n"
+      + "  \"contributors_url\": \"https://github.sonarsource.com/api/v3/repos/octocat/Hello-World/contributors\",\n"
+      + "  \"deployments_url\": \"https://github.sonarsource.com/api/v3/repos/octocat/Hello-World/deployments\",\n"
+      + "  \"downloads_url\": \"https://github.sonarsource.com/api/v3/repos/octocat/Hello-World/downloads\",\n"
+      + "  \"events_url\": \"https://github.sonarsource.com/api/v3/repos/octocat/Hello-World/events\",\n"
+      + "  \"forks_url\": \"https://github.sonarsource.com/api/v3/repos/octocat/Hello-World/forks\",\n"
+      + "  \"git_commits_url\": \"https://github.sonarsource.com/api/v3/repos/octocat/Hello-World/git/commits{/sha}\",\n"
+      + "  \"git_refs_url\": \"https://github.sonarsource.com/api/v3/repos/octocat/Hello-World/git/refs{/sha}\",\n"
+      + "  \"git_tags_url\": \"https://github.sonarsource.com/api/v3/repos/octocat/Hello-World/git/tags{/sha}\",\n"
+      + "  \"git_url\": \"git:github.com/octocat/Hello-World.git\",\n"
+      + "  \"issue_comment_url\": \"https://github.sonarsource.com/api/v3/repos/octocat/Hello-World/issues/comments{/number}\",\n"
+      + "  \"issue_events_url\": \"https://github.sonarsource.com/api/v3/repos/octocat/Hello-World/issues/events{/number}\",\n"
+      + "  \"issues_url\": \"https://github.sonarsource.com/api/v3/repos/octocat/Hello-World/issues{/number}\",\n"
+      + "  \"keys_url\": \"https://github.sonarsource.com/api/v3/repos/octocat/Hello-World/keys{/key_id}\",\n"
+      + "  \"labels_url\": \"https://github.sonarsource.com/api/v3/repos/octocat/Hello-World/labels{/name}\",\n"
+      + "  \"languages_url\": \"https://github.sonarsource.com/api/v3/repos/octocat/Hello-World/languages\",\n"
+      + "  \"merges_url\": \"https://github.sonarsource.com/api/v3/repos/octocat/Hello-World/merges\",\n"
+      + "  \"milestones_url\": \"https://github.sonarsource.com/api/v3/repos/octocat/Hello-World/milestones{/number}\",\n"
+      + "  \"notifications_url\": \"https://github.sonarsource.com/api/v3/repos/octocat/Hello-World/notifications{?since,all,participating}\",\n"
+      + "  \"pulls_url\": \"https://github.sonarsource.com/api/v3/repos/octocat/Hello-World/pulls{/number}\",\n"
+      + "  \"releases_url\": \"https://github.sonarsource.com/api/v3/repos/octocat/Hello-World/releases{/id}\",\n"
+      + "  \"ssh_url\": \"git@github.com:octocat/Hello-World.git\",\n"
+      + "  \"stargazers_url\": \"https://github.sonarsource.com/api/v3/repos/octocat/Hello-World/stargazers\",\n"
+      + "  \"statuses_url\": \"https://github.sonarsource.com/api/v3/repos/octocat/Hello-World/statuses/{sha}\",\n"
+      + "  \"subscribers_url\": \"https://github.sonarsource.com/api/v3/repos/octocat/Hello-World/subscribers\",\n"
+      + "  \"subscription_url\": \"https://github.sonarsource.com/api/v3/repos/octocat/Hello-World/subscription\",\n"
+      + "  \"tags_url\": \"https://github.sonarsource.com/api/v3/repos/octocat/Hello-World/tags\",\n"
+      + "  \"teams_url\": \"https://github.sonarsource.com/api/v3/repos/octocat/Hello-World/teams\",\n"
+      + "  \"trees_url\": \"https://github.sonarsource.com/api/v3/repos/octocat/Hello-World/git/trees{/sha}\",\n"
+      + "  \"clone_url\": \"https://github.com/octocat/Hello-World.git\",\n"
+      + "  \"mirror_url\": \"git:git.example.com/octocat/Hello-World\",\n"
+      + "  \"hooks_url\": \"https://github.sonarsource.com/api/v3/repos/octocat/Hello-World/hooks\",\n"
+      + "  \"svn_url\": \"https://svn.github.com/octocat/Hello-World\",\n"
+      + "  \"homepage\": \"https://github.com\",\n"
+      + "  \"language\": null,\n"
+      + "  \"forks_count\": 9,\n"
+      + "  \"stargazers_count\": 80,\n"
+      + "  \"watchers_count\": 80,\n"
+      + "  \"size\": 108,\n"
+      + "  \"default_branch\": \"master\",\n"
+      + "  \"open_issues_count\": 0,\n"
+      + "  \"is_template\": true,\n"
+      + "  \"topics\": [\n"
+      + "    \"octocat\",\n"
+      + "    \"atom\",\n"
+      + "    \"electron\",\n"
+      + "    \"api\"\n"
+      + "  ],\n"
+      + "  \"has_issues\": true,\n"
+      + "  \"has_projects\": true,\n"
+      + "  \"has_wiki\": true,\n"
+      + "  \"has_pages\": false,\n"
+      + "  \"has_downloads\": true,\n"
+      + "  \"archived\": false,\n"
+      + "  \"disabled\": false,\n"
+      + "  \"visibility\": \"public\",\n"
+      + "  \"pushed_at\": \"2011-01-26T19:06:43Z\",\n"
+      + "  \"created_at\": \"2011-01-26T19:01:12Z\",\n"
+      + "  \"updated_at\": \"2011-01-26T19:14:43Z\",\n"
+      + "  \"permissions\": {\n"
+      + "    \"admin\": false,\n"
+      + "    \"push\": false,\n"
+      + "    \"pull\": true\n"
+      + "  },\n"
+      + "  \"allow_rebase_merge\": true,\n"
+      + "  \"template_repository\": null,\n"
+      + "  \"allow_squash_merge\": true,\n"
+      + "  \"allow_merge_commit\": true,\n"
+      + "  \"subscribers_count\": 42,\n"
+      + "  \"network_count\": 0,\n"
+      + "  \"anonymous_access_enabled\": false,\n"
+      + "  \"license\": {\n"
+      + "    \"key\": \"mit\",\n"
+      + "    \"name\": \"MIT License\",\n"
+      + "    \"spdx_id\": \"MIT\",\n"
+      + "    \"url\": \"https://github.sonarsource.com/api/v3/licenses/mit\",\n"
+      + "    \"node_id\": \"MDc6TGljZW5zZW1pdA==\"\n"
+      + "  },\n"
+      + "  \"organization\": {\n"
+      + "    \"login\": \"octocat\",\n"
+      + "    \"id\": 1,\n"
+      + "    \"node_id\": \"MDQ6VXNlcjE=\",\n"
+      + "    \"avatar_url\": \"https://github.com/images/error/octocat_happy.gif\",\n"
+      + "    \"gravatar_id\": \"\",\n"
+      + "    \"url\": \"https://github.sonarsource.com/api/v3/users/octocat\",\n"
+      + "    \"html_url\": \"https://github.com/octocat\",\n"
+      + "    \"followers_url\": \"https://github.sonarsource.com/api/v3/users/octocat/followers\",\n"
+      + "    \"following_url\": \"https://github.sonarsource.com/api/v3/users/octocat/following{/other_user}\",\n"
+      + "    \"gists_url\": \"https://github.sonarsource.com/api/v3/users/octocat/gists{/gist_id}\",\n"
+      + "    \"starred_url\": \"https://github.sonarsource.com/api/v3/users/octocat/starred{/owner}{/repo}\",\n"
+      + "    \"subscriptions_url\": \"https://github.sonarsource.com/api/v3/users/octocat/subscriptions\",\n"
+      + "    \"organizations_url\": \"https://github.sonarsource.com/api/v3/users/octocat/orgs\",\n"
+      + "    \"repos_url\": \"https://github.sonarsource.com/api/v3/users/octocat/repos\",\n"
+      + "    \"events_url\": \"https://github.sonarsource.com/api/v3/users/octocat/events{/privacy}\",\n"
+      + "    \"received_events_url\": \"https://github.sonarsource.com/api/v3/users/octocat/received_events\",\n"
+      + "    \"type\": \"Organization\",\n"
+      + "    \"site_admin\": false\n"
+      + "  }"
+      + "}";
 
     when(githubApplicationHttpClient.get(appUrl, accessToken, "/repos/octocat/Hello-World"))
       .thenReturn(new GetResponse() {
@@ -1022,7 +1038,7 @@ public class GithubApplicationClientImplTest {
   public void createAppInstallationToken_returns_empty_if_post_throws_IOE() throws IOException {
     mockAppToken();
     when(githubApplicationHttpClient.post(anyString(), any(AccessToken.class), anyString())).thenThrow(IOException.class);
-    Optional<AppInstallationToken> accessToken = underTest.createAppInstallationToken(githubAppConfiguration, INSTALLATION_ID);
+    Optional<ExpiringAppInstallationToken> accessToken = underTest.createAppInstallationToken(githubAppConfiguration, INSTALLATION_ID);
 
     assertThat(accessToken).isEmpty();
     assertThat(logTester.getLogs(Level.WARN)).extracting(LogAndArguments::getRawMsg).anyMatch(s -> s.startsWith("Failed to request"));
@@ -1033,7 +1049,7 @@ public class GithubApplicationClientImplTest {
     AppToken appToken = mockAppToken();
     mockAccessTokenCallingGithubFailure();
 
-    Optional<AppInstallationToken> accessToken = underTest.createAppInstallationToken(githubAppConfiguration, INSTALLATION_ID);
+    Optional<ExpiringAppInstallationToken> accessToken = underTest.createAppInstallationToken(githubAppConfiguration, INSTALLATION_ID);
 
     assertThat(accessToken).isEmpty();
     verify(githubApplicationHttpClient).post(appUrl, appToken, "/app/installations/" + INSTALLATION_ID + "/access_tokens");
@@ -1042,9 +1058,9 @@ public class GithubApplicationClientImplTest {
   @Test
   public void createAppInstallationToken_from_installation_id_returns_access_token() throws IOException {
     AppToken appToken = mockAppToken();
-    AppInstallationToken installToken = mockCreateAccessTokenCallingGithub();
+    ExpiringAppInstallationToken installToken = mockCreateAccessTokenCallingGithub();
 
-    Optional<AppInstallationToken> accessToken = underTest.createAppInstallationToken(githubAppConfiguration, INSTALLATION_ID);
+    Optional<ExpiringAppInstallationToken> accessToken = underTest.createAppInstallationToken(githubAppConfiguration, INSTALLATION_ID);
 
     assertThat(accessToken).hasValue(installToken);
     verify(githubApplicationHttpClient).post(appUrl, appToken, "/app/installations/" + INSTALLATION_ID + "/access_tokens");
@@ -1128,15 +1144,25 @@ public class GithubApplicationClientImplTest {
     return new AppToken(jwt);
   }
 
-  private AppInstallationToken mockCreateAccessTokenCallingGithub() throws IOException {
+  private ExpiringAppInstallationToken mockCreateAccessTokenCallingGithub() throws IOException {
     String token = randomAlphanumeric(5);
     Response response = mock(Response.class);
-    when(response.getContent()).thenReturn(Optional.of("{" +
-                                                       "  \"token\": \"" + token + "\"" +
-                                                       "}"));
+    when(response.getContent()).thenReturn(Optional.of(format("""
+          {
+               "token": "%s",
+               "expires_at": "2024-08-28T10:44:51Z",
+               "permissions": {
+                       "members": "read",
+                       "organization_administration": "read",
+                       "administration": "read",
+                       "metadata": "read"
+               },
+               "repository_selection": "all"
+        }
+      """, token)));
     when(response.getCode()).thenReturn(HTTP_CREATED);
     when(githubApplicationHttpClient.post(eq(appUrl), any(AppToken.class), eq("/app/installations/" + INSTALLATION_ID + "/access_tokens"))).thenReturn(response);
-    return new AppInstallationToken(token);
+    return new ExpiringAppInstallationToken(clock, token, "2024-08-28T10:44:51Z");
   }
 
   private static class OkGetResponse extends Response {
index 9b0736ee09c19d174199234c4b14b5e880821dcf..363886bb33262793d30accd1a3f80aea196c06da 100644 (file)
@@ -24,7 +24,9 @@ dependencies {
     testImplementation 'junit:junit'
     testImplementation 'org.assertj:assertj-core'
     testImplementation 'org.junit.jupiter:junit-jupiter-api'
+    testImplementation 'org.junit.jupiter:junit-jupiter-params'
     testImplementation 'org.mockito:mockito-core'
+    testImplementation 'org.mockito:mockito-junit-jupiter'
     testImplementation testFixtures(project(':server:sonar-db-dao'))
     testImplementation project(path: ':server:sonar-webserver-api')
 
index 04a9b817695089ec15ee8a4b86078b784df85cfe..3c323051238ec1f6e6e030fd5c1d1a0fc8932451 100644 (file)
  */
 package org.sonar.auth.github;
 
-import javax.annotation.concurrent.Immutable;
 import org.sonar.auth.github.security.AccessToken;
 
-import static java.util.Objects.requireNonNull;
-
-/**
- * Token that provides access to the Github API on behalf of
- * the Github organization that installed the Github App.
- *
- * It expires after one hour.
- *
- * IMPORTANT
- * Rate limit is 5'000 API requests per hour for the Github organization.
- * Two different Github organizations don't share rate limits.
- * Two different instances of {@link AppInstallationToken} of the same Github organization
- * share the same quotas (two calls from the two different instances consume
- * two hits).
- *
- * The limit can be higher than 5'000, depending on the number of repositories
- * and users present in the organization. See
- * https://developer.github.com/apps/building-github-apps/understanding-rate-limits-for-github-apps/
- *
- * When the token is expired, the rate limit is 60 calls per hour for the public IP
- * of the machine. BE CAREFUL, THAT SHOULD NEVER OCCUR.
- *
- * See https://developer.github.com/apps/building-github-apps/authenticating-with-github-apps/#authenticating-as-an-installation
- */
-@Immutable
-public class AppInstallationToken implements AccessToken {
-
-  private final String token;
-
-  public AppInstallationToken(String token) {
-    this.token = requireNonNull(token, "token can't be null");
-  }
+public interface AppInstallationToken extends AccessToken {
 
   @Override
-  public String getValue() {
-    return token;
-  }
+  String getValue();
 
   @Override
-  public String getAuthorizationHeaderPrefix() {
+  default String getAuthorizationHeaderPrefix() {
     return "Token";
   }
 
-  @Override
-  public boolean equals(Object o) {
-    if (this == o) {
-      return true;
-    }
-    if (o == null || getClass() != o.getClass()) {
-      return false;
-    }
-    AppInstallationToken that = (AppInstallationToken) o;
-    return token.equals(that.token);
-  }
-
-  @Override
-  public int hashCode() {
-    return token.hashCode();
-  }
-
-  @Override
-  public String toString() {
-    return token;
-  }
 }
diff --git a/server/sonar-auth-github/src/main/java/org/sonar/auth/github/AppInstallationTokenGenerator.java b/server/sonar-auth-github/src/main/java/org/sonar/auth/github/AppInstallationTokenGenerator.java
new file mode 100644 (file)
index 0000000..005bab0
--- /dev/null
@@ -0,0 +1,43 @@
+/*
+ * SonarQube
+ * Copyright (C) 2009-2024 SonarSource SA
+ * mailto:info AT sonarsource DOT com
+ *
+ * This program is free software; you can redistribute it and/or
+ * modify it under the terms of the GNU Lesser General Public
+ * License as published by the Free Software Foundation; either
+ * version 3 of the License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
+ * Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public License
+ * along with this program; if not, write to the Free Software Foundation,
+ * Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
+ */
+package org.sonar.auth.github;
+
+import org.sonar.api.ce.ComputeEngineSide;
+import org.sonar.auth.github.client.GithubApplicationClient;
+
+import static java.lang.Long.parseLong;
+import static java.lang.String.format;
+
+@ComputeEngineSide
+public class AppInstallationTokenGenerator {
+  private final GithubAppConfiguration githubAppConfiguration;
+  private final GithubApplicationClient githubApp;
+
+  AppInstallationTokenGenerator(GithubAppConfiguration githubAppConfiguration, GithubApplicationClient githubApp) {
+    this.githubAppConfiguration = githubAppConfiguration;
+    this.githubApp = githubApp;
+  }
+
+  public ExpiringAppInstallationToken getAppInstallationToken(GithubAppInstallation githubAppInstallation) {
+    return githubApp.createAppInstallationToken(githubAppConfiguration, parseLong(githubAppInstallation.installationId()))
+      .orElseThrow(() -> new IllegalStateException(format("Error while generating token for GitHub app installed in organization %s", githubAppInstallation.organizationName())));
+  }
+
+}
diff --git a/server/sonar-auth-github/src/main/java/org/sonar/auth/github/AutoRefreshableAppToken.java b/server/sonar-auth-github/src/main/java/org/sonar/auth/github/AutoRefreshableAppToken.java
new file mode 100644 (file)
index 0000000..2b11be7
--- /dev/null
@@ -0,0 +1,57 @@
+/*
+ * SonarQube
+ * Copyright (C) 2009-2024 SonarSource SA
+ * mailto:info AT sonarsource DOT com
+ *
+ * This program is free software; you can redistribute it and/or
+ * modify it under the terms of the GNU Lesser General Public
+ * License as published by the Free Software Foundation; either
+ * version 3 of the License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
+ * Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public License
+ * along with this program; if not, write to the Free Software Foundation,
+ * Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
+ */
+package org.sonar.auth.github;
+
+import javax.annotation.concurrent.Immutable;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+@Immutable
+public class AutoRefreshableAppToken implements AppInstallationToken {
+  private static final Logger LOG = LoggerFactory.getLogger(AutoRefreshableAppToken.class);
+
+  private final AppInstallationTokenGenerator appInstallationTokenGenerator;
+  private final GithubAppInstallation githubAppInstallation;
+  private ExpiringAppInstallationToken expiringAppInstallationToken = null;
+
+  public AutoRefreshableAppToken(AppInstallationTokenGenerator appInstallationTokenGenerator, GithubAppInstallation githubAppInstallation) {
+    this.appInstallationTokenGenerator = appInstallationTokenGenerator;
+    this.githubAppInstallation = githubAppInstallation;
+  }
+
+  @Override
+  public String getValue() {
+    return getAppToken().getValue();
+  }
+
+  @Override
+  public String getAuthorizationHeaderPrefix() {
+    return getAppToken().getAuthorizationHeaderPrefix();
+  }
+
+  private ExpiringAppInstallationToken getAppToken() {
+    if (expiringAppInstallationToken == null || expiringAppInstallationToken.isExpired()) {
+      LOG.debug("Refreshing GitHub app token for organization {}", githubAppInstallation.organizationName());
+      expiringAppInstallationToken = appInstallationTokenGenerator.getAppInstallationToken(githubAppInstallation);
+    }
+    return expiringAppInstallationToken;
+  }
+
+}
diff --git a/server/sonar-auth-github/src/main/java/org/sonar/auth/github/ExpiringAppInstallationToken.java b/server/sonar-auth-github/src/main/java/org/sonar/auth/github/ExpiringAppInstallationToken.java
new file mode 100644 (file)
index 0000000..151241a
--- /dev/null
@@ -0,0 +1,96 @@
+/*
+ * SonarQube
+ * Copyright (C) 2009-2024 SonarSource SA
+ * mailto:info AT sonarsource DOT com
+ *
+ * This program is free software; you can redistribute it and/or
+ * modify it under the terms of the GNU Lesser General Public
+ * License as published by the Free Software Foundation; either
+ * version 3 of the License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
+ * Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public License
+ * along with this program; if not, write to the Free Software Foundation,
+ * Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
+ */
+package org.sonar.auth.github;
+
+import java.time.Clock;
+import java.time.OffsetDateTime;
+import java.time.format.DateTimeFormatter;
+import javax.annotation.concurrent.Immutable;
+
+import static java.util.Objects.requireNonNull;
+
+/**
+ * Token that provides access to the Github API on behalf of
+ * the Github organization that installed the Github App.
+ *
+ * IMPORTANT
+ * Two different Github organizations don't share rate limits.
+ * Two different instances of {@link ExpiringAppInstallationToken} of the same Github organization
+ * share the same quotas (two calls from the two different instances consume
+ * two hits).
+ *
+ * The limit can be higher than 5'000, depending on the number of repositories
+ * and users present in the organization. See
+ * https://developer.github.com/apps/building-github-apps/understanding-rate-limits-for-github-apps/
+ *
+ * When the token is expired, the rate limit is 60 calls per hour for the public IP
+ * of the machine. BE CAREFUL, THAT SHOULD NEVER OCCUR.
+ *
+ * See https://developer.github.com/apps/building-github-apps/authenticating-with-github-apps/#authenticating-as-an-installation
+ */
+@Immutable
+public class ExpiringAppInstallationToken implements AppInstallationToken {
+
+  private final Clock clock;
+  private final String token;
+  private final OffsetDateTime expiresAt;
+
+  public ExpiringAppInstallationToken(Clock clock, String token, String expiresAt) {
+    this.clock = clock;
+    this.token = requireNonNull(token, "token can't be null");
+    this.expiresAt = OffsetDateTime.parse(expiresAt, DateTimeFormatter.ISO_DATE_TIME);
+  }
+
+  @Override
+  public String getValue() {
+    return token;
+  }
+
+  @Override
+  public String getAuthorizationHeaderPrefix() {
+    return "Token";
+  }
+
+  public boolean isExpired() {
+    return expiresAt.minusMinutes(1).isBefore(OffsetDateTime.now(clock));
+  }
+
+  @Override
+  public boolean equals(Object o) {
+    if (this == o) {
+      return true;
+    }
+    if (o == null || getClass() != o.getClass()) {
+      return false;
+    }
+    ExpiringAppInstallationToken that = (ExpiringAppInstallationToken) o;
+    return token.equals(that.token);
+  }
+
+  @Override
+  public int hashCode() {
+    return token.hashCode();
+  }
+
+  @Override
+  public String toString() {
+    return token;
+  }
+}
index 65759e010a21a8770ec2920c7887caa57bdac148..3a1d7390b2c97bf5efb9a41e4647602719498cb3 100644 (file)
@@ -485,8 +485,15 @@ public class GithubBinding {
     @SerializedName("token")
     String token;
 
+    @SerializedName("expires_at")
+    String expiresAt;
+
     public String getToken() {
       return token;
     }
+
+    public String getExpiresAt() {
+      return expiresAt;
+    }
   }
 }
index ef8a6285dfdf8c8d767046f5e31ccd91a6ae1854..a2b41e081a3172776d1fbd5444a41c7566c2c0b3 100644 (file)
@@ -27,7 +27,7 @@ import javax.annotation.CheckForNull;
 import javax.annotation.Nullable;
 import javax.annotation.concurrent.Immutable;
 import org.sonar.api.server.ServerSide;
-import org.sonar.auth.github.AppInstallationToken;
+import org.sonar.auth.github.ExpiringAppInstallationToken;
 import org.sonar.auth.github.GithubAppConfiguration;
 import org.sonar.auth.github.GithubAppInstallation;
 import org.sonar.auth.github.GithubBinding;
@@ -60,7 +60,7 @@ public interface GithubApplicationClient {
    * @return {@code Optional.empty()} if Github is not configured or if token failed to be
    *         created (network issue, parsing error, Github error, ...).
    */
-  Optional<AppInstallationToken> createAppInstallationToken(GithubAppConfiguration githubAppConfiguration, long installationId);
+  Optional<ExpiringAppInstallationToken> createAppInstallationToken(GithubAppConfiguration githubAppConfiguration, long installationId);
 
   GithubBinding.GsonApp getApp(GithubAppConfiguration githubAppConfiguration);
 
diff --git a/server/sonar-auth-github/src/test/java/org/sonar/auth/github/AppInstallationTokenGeneratorTest.java b/server/sonar-auth-github/src/test/java/org/sonar/auth/github/AppInstallationTokenGeneratorTest.java
new file mode 100644 (file)
index 0000000..5e508f0
--- /dev/null
@@ -0,0 +1,76 @@
+/*
+ * SonarQube
+ * Copyright (C) 2009-2024 SonarSource SA
+ * mailto:info AT sonarsource DOT com
+ *
+ * This program is free software; you can redistribute it and/or
+ * modify it under the terms of the GNU Lesser General Public
+ * License as published by the Free Software Foundation; either
+ * version 3 of the License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
+ * Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public License
+ * along with this program; if not, write to the Free Software Foundation,
+ * Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
+ */
+package org.sonar.auth.github;
+
+import java.util.Optional;
+import javax.annotation.Nullable;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.mockito.InjectMocks;
+import org.mockito.Mock;
+import org.mockito.junit.MockitoJUnitRunner;
+import org.sonar.auth.github.client.GithubApplicationClient;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatExceptionOfType;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.when;
+
+@RunWith(MockitoJUnitRunner.class)
+public class AppInstallationTokenGeneratorTest {
+  private static final String ORG_NAME = "ORG_NAME";
+  private static final String INSTALLATION_ID = "1234";
+
+  @Mock
+  private GithubAppConfiguration githubAppConfiguration;
+  @Mock
+  private GithubApplicationClient githubApp;
+  @Mock
+  private GithubAppInstallation githubAppInstallation;
+
+  @InjectMocks
+  private AppInstallationTokenGenerator appInstallationTokenGenerator;
+
+  @Test
+  public void getAppInstallationToken_whenTokenGeneratedByGithubApp_returnsIt() {
+    ExpiringAppInstallationToken appInstallationToken = mock();
+    mockTokenCreation(appInstallationToken);
+
+    AppInstallationToken appInstallationTokenFromGenerator = appInstallationTokenGenerator.getAppInstallationToken(githubAppInstallation);
+
+    assertThat(appInstallationTokenFromGenerator).isSameAs(appInstallationToken);
+  }
+
+  @Test
+  public void getAppInstallationToken_whenTokenNotGeneratedByGithubApp_throws() {
+    mockTokenCreation(null);
+
+    assertThatExceptionOfType(IllegalStateException.class)
+      .isThrownBy(() -> appInstallationTokenGenerator.getAppInstallationToken(githubAppInstallation))
+      .withMessage("Error while generating token for GitHub app installed in organization ORG_NAME");
+  }
+
+  private void mockTokenCreation(@Nullable ExpiringAppInstallationToken appInstallationToken) {
+    when(githubAppInstallation.organizationName()).thenReturn(ORG_NAME);
+    when(githubAppInstallation.installationId()).thenReturn(INSTALLATION_ID);
+    when(githubApp.createAppInstallationToken(githubAppConfiguration, Long.parseLong(INSTALLATION_ID))).thenReturn(Optional.ofNullable(appInstallationToken));
+  }
+
+}
diff --git a/server/sonar-auth-github/src/test/java/org/sonar/auth/github/AppInstallationTokenTest.java b/server/sonar-auth-github/src/test/java/org/sonar/auth/github/AppInstallationTokenTest.java
deleted file mode 100644 (file)
index dd943ed..0000000
+++ /dev/null
@@ -1,50 +0,0 @@
-/*
- * SonarQube
- * Copyright (C) 2009-2024 SonarSource SA
- * mailto:info AT sonarsource DOT com
- *
- * This program is free software; you can redistribute it and/or
- * modify it under the terms of the GNU Lesser General Public
- * License as published by the Free Software Foundation; either
- * version 3 of the License, or (at your option) any later version.
- *
- * This program is distributed in the hope that it will be useful,
- * but WITHOUT ANY WARRANTY; without even the implied warranty of
- * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
- * Lesser General Public License for more details.
- *
- * You should have received a copy of the GNU Lesser General Public License
- * along with this program; if not, write to the Free Software Foundation,
- * Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
- */
-package org.sonar.auth.github;
-
-import org.junit.Test;
-
-import static org.assertj.core.api.Assertions.assertThat;
-
-public class AppInstallationTokenTest {
-
-  @Test
-  public void test_value() {
-    AppInstallationToken underTest = new AppInstallationToken("foo");
-
-    assertThat(underTest.toString())
-      .isEqualTo(underTest.getValue())
-      .isEqualTo("foo");
-    assertThat(underTest.getAuthorizationHeaderPrefix()).isEqualTo("Token");
-  }
-
-  @Test
-  public void test_equals_hashCode() {
-    AppInstallationToken foo = new AppInstallationToken("foo");
-
-    assertThat(foo.equals(foo)).isTrue();
-    assertThat(foo.equals(null)).isFalse();
-    assertThat(foo.equals(new AppInstallationToken("foo"))).isTrue();
-    assertThat(foo.equals(new AppInstallationToken("bar"))).isFalse();
-    assertThat(foo.equals("foo")).isFalse();
-
-    assertThat(foo).hasSameHashCodeAs(new AppInstallationToken("foo"));
-  }
-}
diff --git a/server/sonar-auth-github/src/test/java/org/sonar/auth/github/AutoRefreshableAppTokenTest.java b/server/sonar-auth-github/src/test/java/org/sonar/auth/github/AutoRefreshableAppTokenTest.java
new file mode 100644 (file)
index 0000000..a262317
--- /dev/null
@@ -0,0 +1,105 @@
+/*
+ * SonarQube
+ * Copyright (C) 2009-2024 SonarSource SA
+ * mailto:info AT sonarsource DOT com
+ *
+ * This program is free software; you can redistribute it and/or
+ * modify it under the terms of the GNU Lesser General Public
+ * License as published by the Free Software Foundation; either
+ * version 3 of the License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
+ * Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public License
+ * along with this program; if not, write to the Free Software Foundation,
+ * Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
+ */
+package org.sonar.auth.github;
+
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
+import org.mockito.Mock;
+import org.mockito.junit.jupiter.MockitoExtension;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.mockito.Mockito.lenient;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.times;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+@ExtendWith(MockitoExtension.class)
+class AutoRefreshableAppTokenTest {
+
+  @Mock
+  private AppInstallationTokenGenerator appInstallationTokenGenerator;
+  @Mock
+  private GithubAppInstallation githubAppInstallation;
+
+  @Test
+  void getValue_ifTokenNull_shouldCreateAndDelegate() {
+    ExpiringAppInstallationToken token = mockValidToken();
+
+    AutoRefreshableAppToken autoRefreshableAppToken = new AutoRefreshableAppToken(appInstallationTokenGenerator, githubAppInstallation);
+    String value = autoRefreshableAppToken.getValue();
+
+    verify(appInstallationTokenGenerator).getAppInstallationToken(githubAppInstallation);
+    assertThat(value).isEqualTo(token.getValue());
+  }
+
+  @Test
+  void getValue_shouldCacheToken() {
+    ExpiringAppInstallationToken token = mockValidToken();
+
+    AutoRefreshableAppToken autoRefreshableAppToken = new AutoRefreshableAppToken(appInstallationTokenGenerator, githubAppInstallation);
+    autoRefreshableAppToken.getValue();
+
+    String value = autoRefreshableAppToken.getValue();
+
+    verify(appInstallationTokenGenerator).getAppInstallationToken(githubAppInstallation);
+    assertThat(value).isEqualTo(token.getValue());
+  }
+
+  @Test
+  void getValue_ifTokenExpired_shouldRenewToken() {
+    mockExpiredToken();
+
+    AutoRefreshableAppToken autoRefreshableAppToken = new AutoRefreshableAppToken(appInstallationTokenGenerator, githubAppInstallation);
+    autoRefreshableAppToken.getValue();
+
+    autoRefreshableAppToken.getValue();
+
+    verify(appInstallationTokenGenerator, times(2)).getAppInstallationToken(githubAppInstallation);
+  }
+
+  @Test
+  void getAuthorizationHeaderPrefix_ifTokenNull_shouldCreateAndDelegate() {
+    ExpiringAppInstallationToken token = mockValidToken();
+
+    AutoRefreshableAppToken autoRefreshableAppToken = new AutoRefreshableAppToken(appInstallationTokenGenerator, githubAppInstallation);
+    String value = autoRefreshableAppToken.getAuthorizationHeaderPrefix();
+
+    verify(appInstallationTokenGenerator).getAppInstallationToken(githubAppInstallation);
+    assertThat(value).isEqualTo(token.getAuthorizationHeaderPrefix());
+  }
+
+  private ExpiringAppInstallationToken mockValidToken() {
+    return mockToken(false);
+  }
+
+  private ExpiringAppInstallationToken mockExpiredToken() {
+    return mockToken(true);
+  }
+
+  private ExpiringAppInstallationToken mockToken(boolean expired) {
+    ExpiringAppInstallationToken token = mock();
+    lenient().when(token.isExpired()).thenReturn(expired);
+    lenient().when(token.getAuthorizationHeaderPrefix()).thenReturn("header");
+    lenient().when(token.getValue()).thenReturn("bla");
+    when(appInstallationTokenGenerator.getAppInstallationToken(githubAppInstallation)).thenReturn(token);
+    return token;
+  }
+}
diff --git a/server/sonar-auth-github/src/test/java/org/sonar/auth/github/ExpiringAppInstallationTokenTest.java b/server/sonar-auth-github/src/test/java/org/sonar/auth/github/ExpiringAppInstallationTokenTest.java
new file mode 100644 (file)
index 0000000..0289639
--- /dev/null
@@ -0,0 +1,83 @@
+/*
+ * SonarQube
+ * Copyright (C) 2009-2024 SonarSource SA
+ * mailto:info AT sonarsource DOT com
+ *
+ * This program is free software; you can redistribute it and/or
+ * modify it under the terms of the GNU Lesser General Public
+ * License as published by the Free Software Foundation; either
+ * version 3 of the License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
+ * Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public License
+ * along with this program; if not, write to the Free Software Foundation,
+ * Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
+ */
+package org.sonar.auth.github;
+
+import java.time.Clock;
+import java.time.Instant;
+import java.time.ZoneId;
+import java.time.ZonedDateTime;
+import java.util.stream.Stream;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.Arguments;
+import org.junit.jupiter.params.provider.MethodSource;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+public class ExpiringAppInstallationTokenTest {
+
+  private static final Clock CLOCK = Clock.fixed(Instant.parse("1970-01-11T10:00:00.000Z"), ZoneId.of("+3"));
+  private static final String DATE = "2024-08-28T10:44:51Z";
+
+  @Test
+  void test_value() {
+    AppInstallationToken underTest = new ExpiringAppInstallationToken(CLOCK, "foo", DATE);
+
+    assertThat(underTest.toString())
+      .isEqualTo(underTest.getValue())
+      .isEqualTo("foo");
+    assertThat(underTest.getAuthorizationHeaderPrefix()).isEqualTo("Token");
+  }
+
+  @Test
+  void test_equals_hashCode() {
+    AppInstallationToken foo = new ExpiringAppInstallationToken(CLOCK, "foo", DATE);
+
+    assertThat(foo.equals(foo)).isTrue();
+    assertThat(foo.equals(null)).isFalse();
+    assertThat(foo.equals(new ExpiringAppInstallationToken(CLOCK, "foo", DATE))).isTrue();
+    assertThat(foo.equals(new ExpiringAppInstallationToken(CLOCK, "bar", DATE))).isFalse();
+    assertThat(foo.equals("foo")).isFalse();
+
+    assertThat(foo).hasSameHashCodeAs(new ExpiringAppInstallationToken(CLOCK, "foo", DATE));
+  }
+
+  @ParameterizedTest
+  @MethodSource("dateAndExpiredProvider")
+  void isExpired(String expirationDate, boolean expectedExpired) {
+    System.out.println(ZonedDateTime.ofInstant(CLOCK.instant(), CLOCK.getZone()));
+    ExpiringAppInstallationToken appInstallationToken = new ExpiringAppInstallationToken(CLOCK, "foo", expirationDate);
+
+    assertThat(appInstallationToken.isExpired()).isEqualTo(expectedExpired);
+  }
+
+  public static Stream<Arguments> dateAndExpiredProvider() {
+    return Stream.of(
+      Arguments.of("1970-01-11T12:00+03:00", true),
+      Arguments.of("1970-01-11T12:59+03:00", true),
+      Arguments.of("1970-01-11T13:00+03:00", true),
+      Arguments.of("1970-01-11T13:01+03:00", false),
+      Arguments.of("1970-01-11T14:00+03:00", false),
+      Arguments.of("1970-01-11T14:01+04:00", false),
+      Arguments.of("1970-01-11T14:00+04:00", true)
+      );
+  }
+
+}
index e2a31f07c3c59b2388c65b3dad7da27c84478398..95a409a5705234a20abbd366885021c340d041a1 100644 (file)
@@ -31,6 +31,7 @@ import org.mockito.junit.MockitoJUnitRunner;
 import org.sonar.alm.client.github.GithubGlobalSettingsValidator;
 import org.sonar.alm.client.github.GithubPermissionConverter;
 import org.sonar.auth.github.AppInstallationToken;
+import org.sonar.auth.github.ExpiringAppInstallationToken;
 import org.sonar.auth.github.GitHubSettings;
 import org.sonar.auth.github.client.GithubApplicationClient;
 import org.sonar.db.DbClient;
@@ -94,9 +95,9 @@ public class GithubProjectCreatorFactoryTest {
   @Mock
   private GithubPermissionConverter githubPermissionConverter;
   @Mock
-  private AppInstallationToken appInstallationToken;
+  private ExpiringAppInstallationToken appInstallationToken;
   @Mock
-  private AppInstallationToken authAppInstallationToken;
+  private ExpiringAppInstallationToken authAppInstallationToken;
   @Mock
   private PermissionService permissionService;
   @Mock
index 15b86e860af1b4d4307075a0a23b78d100914328..becb7a2756c9ae0b6da4c814d6de3465c83b0552 100644 (file)
@@ -30,7 +30,7 @@ import org.sonar.alm.client.github.GithubPermissionConverter;
 import org.sonar.api.resources.Qualifiers;
 import org.sonar.api.server.ws.WebService;
 import org.sonar.api.utils.System2;
-import org.sonar.auth.github.AppInstallationToken;
+import org.sonar.auth.github.ExpiringAppInstallationToken;
 import org.sonar.auth.github.GitHubSettings;
 import org.sonar.auth.github.GsonRepositoryCollaborator;
 import org.sonar.auth.github.GsonRepositoryPermissions;
@@ -355,7 +355,7 @@ public class ImportGithubProjectActionIT {
     when(gitHubSettings.privateKey()).thenReturn("private key");
     when(gitHubSettings.apiURL()).thenReturn("http://www.url.com");
 
-    AppInstallationToken appInstallationToken = mock();
+    ExpiringAppInstallationToken appInstallationToken = mock();
 
     when(appClient.getInstallationId(any(), any())).thenReturn(Optional.of(321L));
     when(appClient.createAppInstallationToken(any(), eq(321L))).thenReturn(Optional.of(appInstallationToken));