Skip to content

Commit 95fd10f

Browse files
committed
feat: github apps support
Signed-off-by: Tobias Soloschenko <tsoloschenko@apache.org>
1 parent bc7db63 commit 95fd10f

File tree

2 files changed

+168
-0
lines changed

2 files changed

+168
-0
lines changed

spring-cloud-config-server/pom.xml

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -92,6 +92,31 @@
9292
<groupId>tools.jackson.dataformat</groupId>
9393
<artifactId>jackson-dataformat-yaml</artifactId>
9494
</dependency>
95+
<dependency>
96+
<groupId>io.jsonwebtoken</groupId>
97+
<artifactId>jjwt-api</artifactId>
98+
<version>${jjwt.version}</version>
99+
</dependency>
100+
<dependency>
101+
<groupId>io.jsonwebtoken</groupId>
102+
<artifactId>jjwt-impl</artifactId>
103+
<version>${jjwt.version}</version>
104+
</dependency>
105+
<dependency>
106+
<groupId>io.jsonwebtoken</groupId>
107+
<artifactId>jjwt-jackson</artifactId>
108+
<version>${jjwt.version}</version>
109+
</dependency>
110+
<dependency>
111+
<groupId>org.bouncycastle</groupId>
112+
<artifactId>bcprov-jdk18on</artifactId>
113+
<version>${bouncycastle.version}</version>
114+
</dependency>
115+
<dependency>
116+
<groupId>org.bouncycastle</groupId>
117+
<artifactId>bcpkix-jdk18on</artifactId>
118+
<version>${bouncycastle.version}</version>
119+
</dependency>
95120
<dependency>
96121
<groupId>org.tmatesoft.svnkit</groupId>
97122
<artifactId>svnkit</artifactId>
@@ -296,6 +321,8 @@
296321

297322
<properties>
298323
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
324+
<jjwt.version>0.13.0</jjwt.version>
325+
<bouncycastle.version>1.83</bouncycastle.version>
299326
</properties>
300327
<build>
301328
<plugins>

spring-cloud-config-server/src/main/java/org/springframework/cloud/config/server/support/GitCredentialsProviderFactory.java

Lines changed: 141 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,12 +16,32 @@
1616

1717
package org.springframework.cloud.config.server.support;
1818

19+
import java.io.StringReader;
20+
import java.security.KeyFactory;
21+
import java.security.PrivateKey;
22+
import java.security.spec.PKCS8EncodedKeySpec;
23+
import java.time.Instant;
24+
import java.time.temporal.ChronoUnit;
25+
import java.util.Date;
26+
27+
import com.fasterxml.jackson.databind.ObjectMapper;
28+
import io.jsonwebtoken.Jwts;
1929
import org.apache.commons.logging.Log;
2030
import org.apache.commons.logging.LogFactory;
31+
import org.bouncycastle.asn1.pkcs.PrivateKeyInfo;
32+
import org.bouncycastle.openssl.PEMKeyPair;
33+
import org.bouncycastle.openssl.PEMParser;
2134
import org.eclipse.jgit.transport.CredentialsProvider;
2235
import org.eclipse.jgit.transport.UsernamePasswordCredentialsProvider;
2336

37+
import org.springframework.beans.factory.annotation.Value;
38+
import org.springframework.http.HttpEntity;
39+
import org.springframework.http.HttpHeaders;
40+
import org.springframework.http.HttpMethod;
41+
import org.springframework.http.ResponseEntity;
2442
import org.springframework.util.ClassUtils;
43+
import org.springframework.web.client.RestTemplate;
44+
import org.springframework.web.util.UriComponentsBuilder;
2545

2646
import static org.springframework.util.StringUtils.hasText;
2747

@@ -44,6 +64,40 @@ public class GitCredentialsProviderFactory {
4464
*/
4565
protected boolean awsCodeCommitEnabled = true;
4666

67+
/**
68+
* If the GitHub App mode should be activated.
69+
*/
70+
@Value("${spring.cloud.config.server.git.app:false}")
71+
public boolean isApp;
72+
73+
/**
74+
* The id of the GitHub app.
75+
*/
76+
@Value("${spring.cloud.config.server.git.appId:}")
77+
public String appId;
78+
79+
/**
80+
* The uri of the GitHub api.
81+
*/
82+
@Value("${spring.cloud.config.server.git.apiUri:}")
83+
public String apiUri;
84+
85+
/**
86+
* The installation id of the GitHub app.
87+
*/
88+
@Value("${spring.cloud.config.server.git.installationId:}")
89+
public String installationId;
90+
91+
/**
92+
* The expiration minutes for the jwt token.
93+
*/
94+
@Value("${spring.cloud.config.server.git.jwtExpirationMinutes:0}")
95+
private int jwtExpirationMinutes;
96+
97+
private String jwtToken;
98+
99+
private final ObjectMapper mapper = new ObjectMapper();
100+
47101
/**
48102
* Search for a credential provider that will handle the specified URI. If not found,
49103
* 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
73127
}
74128
else if (hasText(username) && password != null) {
75129
this.logger.debug("Constructing UsernamePasswordCredentialsProvider for URI " + uri);
130+
password = appToken(username, password);
76131
provider = new UsernamePasswordCredentialsProvider(username, password.toCharArray());
77132
}
78133
else if (hasText(passphrase)) {
@@ -116,4 +171,90 @@ public void setAwsCodeCommitEnabled(boolean awsCodeCommitEnabled) {
116171
this.awsCodeCommitEnabled = awsCodeCommitEnabled;
117172
}
118173

174+
/**
175+
* Gets the app token to be used to perform GitHub repository calls with.
176+
*
177+
* @param username the username to authenticate with
178+
* @param password the password to authenticate with
179+
* @return the token to be used as password
180+
*/
181+
private String appToken(String username, String password) {
182+
if (isApp && "x-access-token".equals(username)) {
183+
this.logger.debug("Using GitHub App mode for authentication - please ensure that you use the private key as password.");
184+
try {
185+
// if jwtToken is null or expired, generate a new one and set it to the field, otherwise use the existing one
186+
if (jwtToken == null || Instant.parse(mapper.readTree(jwtToken).get("expires_at").asText()).isBefore(Instant.now())) {
187+
PrivateKey privateKey = loadPkcs1PrivateKey(password);
188+
HttpHeaders headers = new HttpHeaders();
189+
headers.setBearerAuth(generateJwt(appId, privateKey));
190+
headers.set("Accept", "application/vnd.github+json");
191+
HttpEntity<String> entity = new HttpEntity<>(headers);
192+
RestTemplate restTemplate = new RestTemplate();
193+
UriComponentsBuilder uriBuilder =
194+
UriComponentsBuilder.fromUriString(apiUri)
195+
.path("app/installations")
196+
.pathSegment(installationId)
197+
.path("access_tokens");
198+
ResponseEntity<String> response = restTemplate.exchange(
199+
uriBuilder.build().toUriString(),
200+
HttpMethod.POST,
201+
entity,
202+
String.class
203+
);
204+
this.jwtToken = response.getBody();
205+
}
206+
password = mapper.readTree(jwtToken).get("token").asText();
207+
}
208+
catch (Exception e) {
209+
this.logger.error("Fehler beim Parsen des JWT Tokens", e);
210+
}
211+
}
212+
return password;
213+
}
214+
215+
/**
216+
* Converts the password into a private key.
217+
*
218+
* @param password the password to convert
219+
* @return the PrivateKey
220+
*/
221+
private PrivateKey loadPkcs1PrivateKey(String password) {
222+
try (PEMParser pemParser = new PEMParser(new StringReader(password))) {
223+
Object object = pemParser.readObject();
224+
PrivateKeyInfo privateKeyInfo;
225+
if (object instanceof PEMKeyPair pemKeyPair) {
226+
privateKeyInfo = pemKeyPair.getPrivateKeyInfo();
227+
}
228+
else if (object instanceof PrivateKeyInfo privateKeyInfo1) {
229+
privateKeyInfo = privateKeyInfo1;
230+
}
231+
else {
232+
throw new IllegalArgumentException("Unknown PEM object: " + object.getClass());
233+
}
234+
byte[] pkcs8Bytes = privateKeyInfo.getEncoded();
235+
PKCS8EncodedKeySpec spec = new PKCS8EncodedKeySpec(pkcs8Bytes);
236+
KeyFactory kf = KeyFactory.getInstance("RSA");
237+
return kf.generatePrivate(spec);
238+
}
239+
catch (Exception e) {
240+
throw new IllegalStateException("An error occurred while loading the private key", e);
241+
}
242+
}
243+
244+
/**
245+
* Generates a jwt token based on the appId and the privateKey.
246+
*
247+
* @param appId the id of the GitHub App
248+
* @param privateKey the private key to sign with
249+
* @return the jwt to be used for the api call
250+
*/
251+
private String generateJwt(String appId, PrivateKey privateKey) {
252+
Instant now = Instant.now();
253+
return Jwts.builder()
254+
.issuer(appId)
255+
.issuedAt(Date.from(now))
256+
.expiration(Date.from(now.plus(jwtExpirationMinutes, ChronoUnit.MINUTES)))
257+
.signWith(privateKey)
258+
.compact();
259+
}
119260
}

0 commit comments

Comments
 (0)