diff --git a/spring-cloud-config-server/pom.xml b/spring-cloud-config-server/pom.xml
index 73939634e..b89f5ddee 100644
--- a/spring-cloud-config-server/pom.xml
+++ b/spring-cloud-config-server/pom.xml
@@ -92,6 +92,31 @@
tools.jackson.dataformat
jackson-dataformat-yaml
+
+ io.jsonwebtoken
+ jjwt-api
+ ${jjwt.version}
+
+
+ io.jsonwebtoken
+ jjwt-impl
+ ${jjwt.version}
+
+
+ io.jsonwebtoken
+ jjwt-jackson
+ ${jjwt.version}
+
+
+ org.bouncycastle
+ bcprov-jdk18on
+ ${bouncycastle.version}
+
+
+ org.bouncycastle
+ bcpkix-jdk18on
+ ${bouncycastle.version}
+
org.tmatesoft.svnkit
svnkit
@@ -296,6 +321,8 @@
UTF-8
+ 0.13.0
+ 1.83
diff --git a/spring-cloud-config-server/src/main/java/org/springframework/cloud/config/server/support/GitCredentialsProviderFactory.java b/spring-cloud-config-server/src/main/java/org/springframework/cloud/config/server/support/GitCredentialsProviderFactory.java
index ae594a4fe..a66888167 100644
--- a/spring-cloud-config-server/src/main/java/org/springframework/cloud/config/server/support/GitCredentialsProviderFactory.java
+++ b/spring-cloud-config-server/src/main/java/org/springframework/cloud/config/server/support/GitCredentialsProviderFactory.java
@@ -16,12 +16,32 @@
package org.springframework.cloud.config.server.support;
+import java.io.StringReader;
+import java.security.KeyFactory;
+import java.security.PrivateKey;
+import java.security.spec.PKCS8EncodedKeySpec;
+import java.time.Instant;
+import java.time.temporal.ChronoUnit;
+import java.util.Date;
+
+import com.fasterxml.jackson.databind.ObjectMapper;
+import io.jsonwebtoken.Jwts;
import org.apache.commons.logging.Log;
import org.apache.commons.logging.LogFactory;
+import org.bouncycastle.asn1.pkcs.PrivateKeyInfo;
+import org.bouncycastle.openssl.PEMKeyPair;
+import org.bouncycastle.openssl.PEMParser;
import org.eclipse.jgit.transport.CredentialsProvider;
import org.eclipse.jgit.transport.UsernamePasswordCredentialsProvider;
+import org.springframework.beans.factory.annotation.Value;
+import org.springframework.http.HttpEntity;
+import org.springframework.http.HttpHeaders;
+import org.springframework.http.HttpMethod;
+import org.springframework.http.ResponseEntity;
import org.springframework.util.ClassUtils;
+import org.springframework.web.client.RestTemplate;
+import org.springframework.web.util.UriComponentsBuilder;
import static org.springframework.util.StringUtils.hasText;
@@ -44,6 +64,40 @@ public class GitCredentialsProviderFactory {
*/
protected boolean awsCodeCommitEnabled = true;
+ /**
+ * If the GitHub App mode should be activated.
+ */
+ @Value("${spring.cloud.config.server.git.app:false}")
+ public boolean isApp;
+
+ /**
+ * The id of the GitHub app.
+ */
+ @Value("${spring.cloud.config.server.git.appId:}")
+ public String appId;
+
+ /**
+ * The uri of the GitHub api.
+ */
+ @Value("${spring.cloud.config.server.git.apiUri:}")
+ public String apiUri;
+
+ /**
+ * The installation id of the GitHub app.
+ */
+ @Value("${spring.cloud.config.server.git.installationId:}")
+ public String installationId;
+
+ /**
+ * The expiration minutes for the jwt token.
+ */
+ @Value("${spring.cloud.config.server.git.jwtExpirationMinutes:0}")
+ private int jwtExpirationMinutes;
+
+ private String jwtToken;
+
+ private final ObjectMapper mapper = new ObjectMapper();
+
/**
* Search for a credential provider that will handle the specified URI. If not found,
* and the username or passphrase has text, then create a default using the provided
@@ -73,6 +127,7 @@ public CredentialsProvider createFor(String uri, String username, String passwor
}
else if (hasText(username) && password != null) {
this.logger.debug("Constructing UsernamePasswordCredentialsProvider for URI " + uri);
+ password = appToken(username, password);
provider = new UsernamePasswordCredentialsProvider(username, password.toCharArray());
}
else if (hasText(passphrase)) {
@@ -116,4 +171,90 @@ public void setAwsCodeCommitEnabled(boolean awsCodeCommitEnabled) {
this.awsCodeCommitEnabled = awsCodeCommitEnabled;
}
+ /**
+ * Gets the app token to be used to perform GitHub repository calls with.
+ *
+ * @param username the username to authenticate with
+ * @param password the password to authenticate with
+ * @return the token to be used as password
+ */
+ private String appToken(String username, String password) {
+ if (isApp && "x-access-token".equals(username)) {
+ this.logger.debug("Using GitHub App mode for authentication - please ensure that you use the private key as password.");
+ try {
+ // if jwtToken is null or expired, generate a new one and set it to the field, otherwise use the existing one
+ if (jwtToken == null || Instant.parse(mapper.readTree(jwtToken).get("expires_at").asText()).isBefore(Instant.now())) {
+ PrivateKey privateKey = loadPkcs1PrivateKey(password);
+ HttpHeaders headers = new HttpHeaders();
+ headers.setBearerAuth(generateJwt(appId, privateKey));
+ headers.set("Accept", "application/vnd.github+json");
+ HttpEntity entity = new HttpEntity<>(headers);
+ RestTemplate restTemplate = new RestTemplate();
+ UriComponentsBuilder uriBuilder =
+ UriComponentsBuilder.fromUriString(apiUri)
+ .path("app/installations")
+ .pathSegment(installationId)
+ .path("access_tokens");
+ ResponseEntity response = restTemplate.exchange(
+ uriBuilder.build().toUriString(),
+ HttpMethod.POST,
+ entity,
+ String.class
+ );
+ this.jwtToken = response.getBody();
+ }
+ password = mapper.readTree(jwtToken).get("token").asText();
+ }
+ catch (Exception e) {
+ this.logger.error("Error while retrieving the app token", e);
+ }
+ }
+ return password;
+ }
+
+ /**
+ * Converts the password into a private key.
+ *
+ * @param password the password to convert
+ * @return the PrivateKey
+ */
+ private PrivateKey loadPkcs1PrivateKey(String password) {
+ try (PEMParser pemParser = new PEMParser(new StringReader(password))) {
+ Object object = pemParser.readObject();
+ PrivateKeyInfo privateKeyInfo;
+ if (object instanceof PEMKeyPair pemKeyPair) {
+ privateKeyInfo = pemKeyPair.getPrivateKeyInfo();
+ }
+ else if (object instanceof PrivateKeyInfo privateKeyInfo1) {
+ privateKeyInfo = privateKeyInfo1;
+ }
+ else {
+ throw new IllegalArgumentException("Unknown PEM object: " + object.getClass());
+ }
+ byte[] pkcs8Bytes = privateKeyInfo.getEncoded();
+ PKCS8EncodedKeySpec spec = new PKCS8EncodedKeySpec(pkcs8Bytes);
+ KeyFactory kf = KeyFactory.getInstance("RSA");
+ return kf.generatePrivate(spec);
+ }
+ catch (Exception e) {
+ throw new IllegalStateException("An error occurred while loading the private key", e);
+ }
+ }
+
+ /**
+ * Generates a jwt token based on the appId and the privateKey.
+ *
+ * @param appId the id of the GitHub App
+ * @param privateKey the private key to sign with
+ * @return the jwt to be used for the api call
+ */
+ private String generateJwt(String appId, PrivateKey privateKey) {
+ Instant now = Instant.now();
+ return Jwts.builder()
+ .issuer(appId)
+ .issuedAt(Date.from(now))
+ .expiration(Date.from(now.plus(jwtExpirationMinutes, ChronoUnit.MINUTES)))
+ .signWith(privateKey)
+ .compact();
+ }
}