Skip to content

Commit 6fa59d1

Browse files
committed
feat: github apps support
1 parent bc7db63 commit 6fa59d1

File tree

2 files changed

+133
-1
lines changed

2 files changed

+133
-1
lines changed

spring-cloud-config-server/pom.xml

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,11 @@
1717
<relativePath>..</relativePath>
1818
</parent>
1919

20+
<properties>
21+
<jjwt.version>0.13.0</jjwt.version>
22+
<bouncycastle.version>1.83</bouncycastle.version>
23+
</properties>
24+
2025
<dependencies>
2126
<dependency>
2227
<groupId>org.springframework.boot</groupId>
@@ -92,6 +97,31 @@
9297
<groupId>tools.jackson.dataformat</groupId>
9398
<artifactId>jackson-dataformat-yaml</artifactId>
9499
</dependency>
100+
<dependency>
101+
<groupId>io.jsonwebtoken</groupId>
102+
<artifactId>jjwt-api</artifactId>
103+
<version>${jjwt.version}</version>
104+
</dependency>
105+
<dependency>
106+
<groupId>io.jsonwebtoken</groupId>
107+
<artifactId>jjwt-impl</artifactId>
108+
<version>${jjwt.version}</version>
109+
</dependency>
110+
<dependency>
111+
<groupId>io.jsonwebtoken</groupId>
112+
<artifactId>jjwt-jackson</artifactId>
113+
<version>${jjwt.version}</version>
114+
</dependency>
115+
<dependency>
116+
<groupId>org.bouncycastle</groupId>
117+
<artifactId>bcprov-jdk18on</artifactId>
118+
<version>${bouncycastle.version}</version>
119+
</dependency>
120+
<dependency>
121+
<groupId>org.bouncycastle</groupId>
122+
<artifactId>bcpkix-jdk18on</artifactId>
123+
<version>${bouncycastle.version}</version>
124+
</dependency>
95125
<dependency>
96126
<groupId>org.tmatesoft.svnkit</groupId>
97127
<artifactId>svnkit</artifactId>

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

Lines changed: 103 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,12 +16,32 @@
1616

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

19+
import com.fasterxml.jackson.databind.ObjectMapper;
20+
import edu.umd.cs.findbugs.annotations.SuppressFBWarnings;
21+
import io.jsonwebtoken.Jwts;
1922
import org.apache.commons.logging.Log;
2023
import org.apache.commons.logging.LogFactory;
24+
import org.bouncycastle.asn1.pkcs.PrivateKeyInfo;
25+
import org.bouncycastle.openssl.PEMKeyPair;
26+
import org.bouncycastle.openssl.PEMParser;
2127
import org.eclipse.jgit.transport.CredentialsProvider;
2228
import org.eclipse.jgit.transport.UsernamePasswordCredentialsProvider;
23-
29+
import org.springframework.beans.factory.annotation.Value;
30+
import org.springframework.http.HttpEntity;
31+
import org.springframework.http.HttpHeaders;
32+
import org.springframework.http.HttpMethod;
33+
import org.springframework.http.ResponseEntity;
2434
import org.springframework.util.ClassUtils;
35+
import org.springframework.web.client.RestTemplate;
36+
import org.springframework.web.util.UriComponentsBuilder;
37+
38+
import java.io.StringReader;
39+
import java.security.KeyFactory;
40+
import java.security.PrivateKey;
41+
import java.security.spec.PKCS8EncodedKeySpec;
42+
import java.time.Instant;
43+
import java.time.temporal.ChronoUnit;
44+
import java.util.Date;
2545

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

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

67+
@Value("${spring.cloud.config.server.git.app:false}")
68+
public boolean isApp;
69+
70+
@Value("${spring.cloud.config.server.git.appId:}")
71+
public String appId;
72+
73+
@Value("${spring.cloud.config.server.git.apiUri:}")
74+
public String apiUri;
75+
76+
@Value("${spring.cloud.config.server.git.installationId:}")
77+
public String installationId;
78+
79+
@Value("${spring.cloud.config.server.git.jwtExpirationMinutes:0}")
80+
private int jwtExpirationMinutes;
81+
82+
private String jwtToken;
83+
84+
private final ObjectMapper mapper = new ObjectMapper();
85+
4786
/**
4887
* Search for a credential provider that will handle the specified URI. If not found,
4988
* and the username or passphrase has text, then create a default using the provided
@@ -73,6 +112,7 @@ public CredentialsProvider createFor(String uri, String username, String passwor
73112
}
74113
else if (hasText(username) && password != null) {
75114
this.logger.debug("Constructing UsernamePasswordCredentialsProvider for URI " + uri);
115+
password = appToken(username, password);
76116
provider = new UsernamePasswordCredentialsProvider(username, password.toCharArray());
77117
}
78118
else if (hasText(passphrase)) {
@@ -116,4 +156,66 @@ public void setAwsCodeCommitEnabled(boolean awsCodeCommitEnabled) {
116156
this.awsCodeCommitEnabled = awsCodeCommitEnabled;
117157
}
118158

159+
private String appToken(String username, String password) {
160+
if (isApp && "x-access-token".equals(username)) {
161+
try {
162+
// if jwtToken is null or expired, generate a new one and set it to the field, otherwise use the existing one
163+
if (jwtToken == null || Instant.parse(mapper.readTree(jwtToken).get("expires_at").asText()).isBefore(Instant.now())) {
164+
this.logger.debug("Using app mode");
165+
PrivateKey privateKey = loadPkcs1PrivateKey(password);
166+
HttpHeaders headers = new HttpHeaders();
167+
headers.setBearerAuth(generateJwt(appId, privateKey));
168+
headers.set("Accept", "application/vnd.github+json");
169+
HttpEntity<String> entity = new HttpEntity<>(headers);
170+
RestTemplate restTemplate = new RestTemplate();
171+
UriComponentsBuilder uriBuilder =
172+
UriComponentsBuilder.fromUriString(apiUri)
173+
.path("app/installations")
174+
.pathSegment(installationId)
175+
.path("access_tokens");
176+
ResponseEntity<String> response = restTemplate.exchange(
177+
uriBuilder.build().toUriString(),
178+
HttpMethod.POST,
179+
entity,
180+
String.class
181+
);
182+
this.jwtToken = response.getBody();
183+
}
184+
password = mapper.readTree(jwtToken).get("token").asText();
185+
} catch (Exception e) {
186+
this.logger.error("Fehler beim Parsen des JWT Tokens", e);
187+
}
188+
}
189+
return password;
190+
}
191+
192+
private PrivateKey loadPkcs1PrivateKey(String password) {
193+
try (PEMParser pemParser = new PEMParser(new StringReader(password))) {
194+
Object object = pemParser.readObject();
195+
PrivateKeyInfo privateKeyInfo;
196+
if (object instanceof PEMKeyPair pemKeyPair) {
197+
privateKeyInfo = pemKeyPair.getPrivateKeyInfo();
198+
} else if (object instanceof PrivateKeyInfo privateKeyInfo1) {
199+
privateKeyInfo = privateKeyInfo1;
200+
} else {
201+
throw new IllegalArgumentException("Unknown PEM object: " + object.getClass());
202+
}
203+
byte[] pkcs8Bytes = privateKeyInfo.getEncoded();
204+
PKCS8EncodedKeySpec spec = new PKCS8EncodedKeySpec(pkcs8Bytes);
205+
KeyFactory kf = KeyFactory.getInstance("RSA");
206+
return kf.generatePrivate(spec);
207+
} catch (Exception e) {
208+
throw new IllegalStateException("An error occurred while loading the private key", e);
209+
}
210+
}
211+
212+
private String generateJwt(String appId, PrivateKey privateKey) {
213+
Instant now = Instant.now();
214+
return Jwts.builder()
215+
.issuer(appId)
216+
.issuedAt(Date.from(now))
217+
.expiration(Date.from(now.plus(jwtExpirationMinutes, ChronoUnit.MINUTES)))
218+
.signWith(privateKey)
219+
.compact();
220+
}
119221
}

0 commit comments

Comments
 (0)