Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 27 additions & 0 deletions spring-cloud-config-server/pom.xml
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,31 @@
<groupId>tools.jackson.dataformat</groupId>
<artifactId>jackson-dataformat-yaml</artifactId>
</dependency>
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-api</artifactId>
<version>${jjwt.version}</version>
</dependency>
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-impl</artifactId>
<version>${jjwt.version}</version>
</dependency>
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-jackson</artifactId>
<version>${jjwt.version}</version>
</dependency>
<dependency>
<groupId>org.bouncycastle</groupId>
<artifactId>bcprov-jdk18on</artifactId>
<version>${bouncycastle.version}</version>
</dependency>
<dependency>
<groupId>org.bouncycastle</groupId>
<artifactId>bcpkix-jdk18on</artifactId>
<version>${bouncycastle.version}</version>
</dependency>
<dependency>
<groupId>org.tmatesoft.svnkit</groupId>
<artifactId>svnkit</artifactId>
Expand Down Expand Up @@ -296,6 +321,8 @@

<properties>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
<jjwt.version>0.13.0</jjwt.version>
<bouncycastle.version>1.83</bouncycastle.version>
</properties>
<build>
<plugins>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand All @@ -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
Expand Down Expand Up @@ -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)) {
Expand Down Expand Up @@ -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<String> entity = new HttpEntity<>(headers);
RestTemplate restTemplate = new RestTemplate();
UriComponentsBuilder uriBuilder =
UriComponentsBuilder.fromUriString(apiUri)
.path("app/installations")
.pathSegment(installationId)
.path("access_tokens");
ResponseEntity<String> 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();
}
}
Loading