Skip to content

Commit 8367aeb

Browse files
committed
Add accurate token expiration and agent-side caching
1 parent e48eb83 commit 8367aeb

1 file changed

Lines changed: 138 additions & 28 deletions

File tree

src/main/java/org/jenkinsci/plugins/github_branch_source/GitHubAppCredentials.java

Lines changed: 138 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,13 @@
1414
import hudson.util.ListBoxModel;
1515
import hudson.util.Secret;
1616
import java.io.IOException;
17+
import java.io.Serializable;
18+
import java.time.Duration;
19+
import java.time.Instant;
1720
import java.util.List;
21+
import java.util.logging.Level;
22+
import java.util.logging.Logger;
23+
1824
import jenkins.security.SlaveToMasterCallable;
1925
import jenkins.util.JenkinsJVM;
2026
import org.kohsuke.accmod.Restricted;
@@ -34,6 +40,8 @@
3440
@SuppressFBWarnings(value = "SE_NO_SERIALVERSIONID", justification = "XStream")
3541
public class GitHubAppCredentials extends BaseStandardCredentials implements StandardUsernamePasswordCredentials {
3642

43+
private static final Logger LOGGER = Logger.getLogger(GitHubAppCredentials.class.getName());
44+
3745
private static final String ERROR_AUTHENTICATING_GITHUB_APP = "Couldn't authenticate with GitHub app ID %s";
3846
private static final String NOT_INSTALLED = ", has it been installed to your GitHub organisation / user?";
3947

@@ -49,8 +57,7 @@ public class GitHubAppCredentials extends BaseStandardCredentials implements Sta
4957

5058
private String owner;
5159

52-
private transient String cachedToken;
53-
private transient long tokenCacheTime;
60+
private transient AppInstallationToken cachedToken;
5461

5562
@DataBoundConstructor
5663
@SuppressWarnings("unused") // by stapler
@@ -104,7 +111,7 @@ public void setOwner(String owner) {
104111
}
105112

106113
@SuppressWarnings("deprecation") // preview features are required for GitHub app integration, GitHub api adds deprecated to all preview methods
107-
static String generateAppInstallationToken(String appId, String appPrivateKey, String apiUrl, String owner) {
114+
static AppInstallationToken generateAppInstallationToken(String appId, String appPrivateKey, String apiUrl, String owner) {
108115
try {
109116
String jwtToken = createJWT(appId, appPrivateKey);
110117
GitHub gitHubApp = Connector
@@ -132,11 +139,29 @@ static String generateAppInstallationToken(String appId, String appPrivateKey, S
132139
.createToken(appInstallation.getPermissions())
133140
.create();
134141

135-
return appInstallationToken.getToken();
142+
long expiration = getExpirationSeconds(appInstallationToken);
143+
144+
LOGGER.log(Level.FINE, "Generated App Installation Token for app ID {0}", appId);
145+
146+
return new AppInstallationToken(appInstallationToken.getToken(), expiration);
136147
} catch (IOException e) {
148+
LOGGER.log(Level.WARNING, "Failed to retrieve GitHub App installation token for app ID " + appId, e);
137149
throw new IllegalArgumentException(String.format(ERROR_AUTHENTICATING_GITHUB_APP, appId), e);
138150
}
151+
}
139152

153+
private static long getExpirationSeconds(GHAppInstallationToken appInstallationToken) {
154+
try {
155+
return appInstallationToken.getExpiresAt()
156+
.toInstant()
157+
.getEpochSecond();
158+
} catch (Exception e) {
159+
// if we fail to calculate the expiration, guess at a reasonable value.
160+
LOGGER.log(Level.WARNING,
161+
"Unable to get GitHub App installation token expiration",
162+
e);
163+
return Instant.now().getEpochSecond() + AppInstallationToken.MAXIMUM_AGE_SECONDS;
164+
}
140165
}
141166

142167
@NonNull String actualApiUri() {
@@ -149,15 +174,15 @@ static String generateAppInstallationToken(String appId, String appPrivateKey, S
149174
@NonNull
150175
@Override
151176
public Secret getPassword() {
152-
long now = System.currentTimeMillis();
153177
String appInstallationToken;
154-
if (cachedToken != null && now - tokenCacheTime < JwtHelper.VALIDITY_MS /* extra buffer */ / 2) {
155-
appInstallationToken = cachedToken;
156-
} else {
157-
appInstallationToken = generateAppInstallationToken(appID, privateKey.getPlainText(), actualApiUri(), owner);
158-
cachedToken = appInstallationToken;
159-
tokenCacheTime = now;
178+
synchronized (this) {
179+
if (cachedToken == null || cachedToken.isStale()) {
180+
LOGGER.log(Level.FINE, "Generating App Installation Token for app ID {0}", appID);
181+
cachedToken = generateAppInstallationToken(appID, privateKey.getPlainText(), actualApiUri(), owner);
182+
}
183+
appInstallationToken = cachedToken.getToken();
160184
}
185+
LOGGER.log(Level.FINER, "Returned GitHub App Installation Token for app ID {0}", appID);
161186

162187
return Secret.fromString(appInstallationToken);
163188
}
@@ -171,48 +196,121 @@ public String getUsername() {
171196
return appID;
172197
}
173198

199+
static class AppInstallationToken implements Serializable {
200+
/**
201+
* {@link #getPassword()} checks that the token is still valid before returning it.
202+
* The token will not expire for at least this amount of time after it is returned.
203+
*
204+
* Using a larger value will result in longer time-to-live for the token, but also more network
205+
* calls related to getting new tokens. Setting a smaller value will result in less token generation
206+
* but runs the the risk of the token expiring while it is still being used.
207+
*
208+
* The time-to-live for the token may be less than this if the initial expiration for the token when
209+
* it is returned from GitHub is less than this.
210+
*/
211+
private final static long MINIMUM_SECONDS_UNTIL_EXPIRATION = Duration.ofMinutes(45).getSeconds();
212+
213+
/**
214+
* Any token older than this is considered stale.
215+
*
216+
* This is a back stop to ensure that, in case of unforeseen error,
217+
* expired tokens are not accidentally retained past their expiration.
218+
*/
219+
private static final long MAXIMUM_AGE_SECONDS = Duration.ofMinutes(30).getSeconds();
220+
221+
private final String token;
222+
private final long tokenStaleEpochSeconds;
223+
224+
/**
225+
* Create a AppInstallationToken instance.
226+
*
227+
* @param token the token string
228+
* @param tokenExpirationEpochSeconds the time in epoch seconds that this token will expire
229+
*/
230+
public AppInstallationToken(String token, long tokenExpirationEpochSeconds) {
231+
long nextSecond = Instant.now().getEpochSecond() + 1;
232+
233+
// Tokens go stale a while before they will expire
234+
long tokenStaleEpochSeconds = tokenExpirationEpochSeconds - MINIMUM_SECONDS_UNTIL_EXPIRATION;
235+
236+
// Tokens are not stale as soon as they are made
237+
if (tokenStaleEpochSeconds < nextSecond) {
238+
tokenStaleEpochSeconds = nextSecond;
239+
} else {
240+
// Tokens have a maximum age at which they go stale
241+
tokenStaleEpochSeconds = Math.min(tokenExpirationEpochSeconds, nextSecond + MAXIMUM_AGE_SECONDS);
242+
}
243+
244+
this.token = token;
245+
this.tokenStaleEpochSeconds = tokenStaleEpochSeconds;
246+
}
247+
248+
public String getToken() {
249+
return token;
250+
}
251+
252+
/**
253+
* Whether a token is stale and should be replaced with a new token.
254+
*
255+
* {@link #getPassword()} checks that the token is not "stale" before returning it.
256+
* If a token is "stale" if it has expired, exceeded {@link #MAXIMUM_AGE_SECONDS}, or
257+
* will expire in less than {@link #MINIMUM_SECONDS_UNTIL_EXPIRATION}.
258+
*
259+
* @return {@code true} if token should be refreshed, otherwise {@code false}.
260+
*/
261+
public boolean isStale() {
262+
return Instant.now().getEpochSecond() >= tokenStaleEpochSeconds;
263+
}
264+
265+
}
266+
174267
/**
175268
* Ensures that the credentials state as serialized via Remoting to an agent calls back to the controller.
176269
* Benefits:
177270
* <ul>
271+
* <li>The token is cached locally and used until it is stale.
178272
* <li>The agent never needs to have access to the plaintext private key.
179-
* <li>We can avoid the considerable amount of class loading associated with the JWT library, Jackson data binding, Bouncy Castle, etc.
273+
* <li>We avoid the considerable amount of class loading associated with the JWT library, Jackson data binding, Bouncy Castle, etc.
180274
* <li>The agent need not be able to contact GitHub.
181275
* </ul>
182-
* Drawbacks:
183-
* <ul>
184-
* <li>There is no caching, so every access requires GitHub API traffic as well as Remoting traffic.
185-
* </ul>
186276
* @see CredentialsSnapshotTaker
187277
*/
188278
private Object writeReplace() {
189279
if (/* XStream */Channel.current() == null) {
190280
return this;
191281
}
192282
return new DelegatingGitHubAppCredentials(this);
193-
}
283+
}
194284

195285
private static final class DelegatingGitHubAppCredentials extends BaseStandardCredentials implements StandardUsernamePasswordCredentials {
196286

197-
static final String SEP = "%%%";
287+
private static final String SEP = "%%%";
198288

199289
private final String appID;
200-
private final String data;
290+
private final String tokenRefreshData;
291+
private AppInstallationToken cachedToken;
292+
201293
private transient Channel ch;
202294

203295
DelegatingGitHubAppCredentials(GitHubAppCredentials onMaster) {
204296
super(onMaster.getScope(), onMaster.getId(), onMaster.getDescription());
205297
JenkinsJVM.checkJenkinsJVM();
206298
appID = onMaster.appID;
207-
data = Secret.fromString(onMaster.appID + SEP + onMaster.privateKey.getPlainText() + SEP + onMaster.actualApiUri() + SEP + onMaster.owner).getEncryptedValue();
299+
tokenRefreshData = Secret.fromString(onMaster.appID + SEP + onMaster.privateKey.getPlainText() + SEP + onMaster.actualApiUri() + SEP + onMaster.owner).getEncryptedValue();
300+
synchronized (onMaster) {
301+
cachedToken = onMaster.cachedToken;
302+
}
208303
}
209304

210305
private Object readResolve() {
211306
JenkinsJVM.checkNotJenkinsJVM();
212-
ch = Channel.currentOrFail();
307+
synchronized (this) {
308+
ch = Channel.currentOrFail();
309+
}
213310
return this;
214311
}
215312

313+
@NonNull
216314
@Override
217315
public String getUsername() {
218316
return appID;
@@ -222,29 +320,41 @@ public String getUsername() {
222320
public Secret getPassword() {
223321
JenkinsJVM.checkNotJenkinsJVM();
224322
try {
225-
return ch.call(new GetPassword(data));
323+
String appInstallationToken;
324+
synchronized (this) {
325+
if (cachedToken == null || cachedToken.isStale()) {
326+
cachedToken = ch.call(new GetToken(tokenRefreshData));
327+
}
328+
appInstallationToken = cachedToken.getToken();
329+
}
330+
LOGGER.log(Level.FINER, "Returned GitHub App Installation Token for app ID {0} on agent", appID);
331+
332+
return Secret.fromString(appInstallationToken);
226333
} catch (IOException | InterruptedException x) {
334+
LOGGER.log(Level.WARNING, "Failed to get GitHub App Installation token on agent: " + getId(), x);
227335
throw new RuntimeException(x);
228336
}
229337
}
230338

231-
private static final class GetPassword extends SlaveToMasterCallable<Secret, RuntimeException> {
339+
private static final class GetToken extends SlaveToMasterCallable<AppInstallationToken, RuntimeException> {
232340

233341
private final String data;
234342

235-
GetPassword(String data) {
343+
GetToken(String data) {
236344
this.data = data;
237345
}
238346

239347
@Override
240-
public Secret call() throws RuntimeException {
348+
public AppInstallationToken call() throws RuntimeException {
241349
JenkinsJVM.checkJenkinsJVM();
242350
String[] fields = Secret.fromString(data).getPlainText().split(SEP);
243-
return Secret.fromString(generateAppInstallationToken(fields[0], fields[1], fields[2], fields[3]));
351+
LOGGER.log(Level.FINE, "Generating App Installation Token for app ID {0} for agent", fields[0]);
352+
return generateAppInstallationToken(fields[0],
353+
fields[1],
354+
fields[2],
355+
fields[3]);
244356
}
245-
246357
}
247-
248358
}
249359

250360
/**

0 commit comments

Comments
 (0)