From f16aafffa24e99d8ed1e07fa3dab6a6a2ddd94b6 Mon Sep 17 00:00:00 2001 From: Tobias Soloschenko Date: Mon, 9 Feb 2026 10:25:08 +0100 Subject: [PATCH] feat: github apps support Signed-off-by: Tobias Soloschenko --- spring-cloud-config-server/pom.xml | 27 ++++ .../GitCredentialsProviderFactory.java | 141 ++++++++++++++++++ 2 files changed, 168 insertions(+) diff --git a/spring-cloud-config-server/pom.xml b/spring-cloud-config-server/pom.xml index 73939634e2..b89f5ddeef 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 ae594a4fe1..a66888167a 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(); + } }