Skip to content

Commit 1136fca

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

File tree

1 file changed

+152
-28
lines changed

1 file changed

+152
-28
lines changed

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

Lines changed: 152 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,135 @@ 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+
this(token, tokenExpirationEpochSeconds, Instant.now().getEpochSecond());
232+
}
233+
234+
235+
/**
236+
* Internal constructor for testing only.
237+
*
238+
* Use {@link #AppInstallationToken(String, long)} instead.
239+
*
240+
* @param token the token string
241+
* @param tokenExpirationEpochSeconds the time in epoch seconds that this token will expire
242+
* @param now current time in epoch seconds.
243+
*/
244+
AppInstallationToken(String token, long tokenExpirationEpochSeconds, long now) {
245+
long nextSecond = now + 1;
246+
247+
// Tokens go stale a while before they will expire
248+
long tokenStaleEpochSeconds = tokenExpirationEpochSeconds - MINIMUM_SECONDS_UNTIL_EXPIRATION;
249+
250+
// Tokens are not stale as soon as they are made
251+
if (tokenStaleEpochSeconds < nextSecond) {
252+
tokenStaleEpochSeconds = nextSecond;
253+
} else {
254+
// Tokens have a maximum age at which they go stale
255+
tokenStaleEpochSeconds = Math.min(tokenExpirationEpochSeconds, nextSecond + MAXIMUM_AGE_SECONDS);
256+
}
257+
258+
this.token = token;
259+
this.tokenStaleEpochSeconds = tokenStaleEpochSeconds;
260+
}
261+
262+
public String getToken() {
263+
return token;
264+
}
265+
266+
/**
267+
* Whether a token is stale and should be replaced with a new token.
268+
*
269+
* {@link #getPassword()} checks that the token is not "stale" before returning it.
270+
* If a token is "stale" if it has expired, exceeded {@link #MAXIMUM_AGE_SECONDS}, or
271+
* will expire in less than {@link #MINIMUM_SECONDS_UNTIL_EXPIRATION}.
272+
*
273+
* @return {@code true} if token should be refreshed, otherwise {@code false}.
274+
*/
275+
public boolean isStale() {
276+
return Instant.now().getEpochSecond() >= tokenStaleEpochSeconds;
277+
}
278+
279+
}
280+
174281
/**
175282
* Ensures that the credentials state as serialized via Remoting to an agent calls back to the controller.
176283
* Benefits:
177284
* <ul>
285+
* <li>The token is cached locally and used until it is stale.
178286
* <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.
287+
* <li>We avoid the considerable amount of class loading associated with the JWT library, Jackson data binding, Bouncy Castle, etc.
180288
* <li>The agent need not be able to contact GitHub.
181289
* </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>
186290
* @see CredentialsSnapshotTaker
187291
*/
188292
private Object writeReplace() {
189293
if (/* XStream */Channel.current() == null) {
190294
return this;
191295
}
192296
return new DelegatingGitHubAppCredentials(this);
193-
}
297+
}
194298

195299
private static final class DelegatingGitHubAppCredentials extends BaseStandardCredentials implements StandardUsernamePasswordCredentials {
196300

197-
static final String SEP = "%%%";
301+
private static final String SEP = "%%%";
198302

199303
private final String appID;
200-
private final String data;
304+
private final String tokenRefreshData;
305+
private AppInstallationToken cachedToken;
306+
201307
private transient Channel ch;
202308

203309
DelegatingGitHubAppCredentials(GitHubAppCredentials onMaster) {
204310
super(onMaster.getScope(), onMaster.getId(), onMaster.getDescription());
205311
JenkinsJVM.checkJenkinsJVM();
206312
appID = onMaster.appID;
207-
data = Secret.fromString(onMaster.appID + SEP + onMaster.privateKey.getPlainText() + SEP + onMaster.actualApiUri() + SEP + onMaster.owner).getEncryptedValue();
313+
tokenRefreshData = Secret.fromString(onMaster.appID + SEP + onMaster.privateKey.getPlainText() + SEP + onMaster.actualApiUri() + SEP + onMaster.owner).getEncryptedValue();
314+
synchronized (onMaster) {
315+
cachedToken = onMaster.cachedToken;
316+
}
208317
}
209318

210319
private Object readResolve() {
211320
JenkinsJVM.checkNotJenkinsJVM();
212-
ch = Channel.currentOrFail();
321+
synchronized (this) {
322+
ch = Channel.currentOrFail();
323+
}
213324
return this;
214325
}
215326

327+
@NonNull
216328
@Override
217329
public String getUsername() {
218330
return appID;
@@ -222,29 +334,41 @@ public String getUsername() {
222334
public Secret getPassword() {
223335
JenkinsJVM.checkNotJenkinsJVM();
224336
try {
225-
return ch.call(new GetPassword(data));
337+
String appInstallationToken;
338+
synchronized (this) {
339+
if (cachedToken == null || cachedToken.isStale()) {
340+
cachedToken = ch.call(new GetToken(tokenRefreshData));
341+
}
342+
appInstallationToken = cachedToken.getToken();
343+
}
344+
LOGGER.log(Level.FINER, "Returned GitHub App Installation Token for app ID {0} on agent", appID);
345+
346+
return Secret.fromString(appInstallationToken);
226347
} catch (IOException | InterruptedException x) {
348+
LOGGER.log(Level.WARNING, "Failed to get GitHub App Installation token on agent: " + getId(), x);
227349
throw new RuntimeException(x);
228350
}
229351
}
230352

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

233355
private final String data;
234356

235-
GetPassword(String data) {
357+
GetToken(String data) {
236358
this.data = data;
237359
}
238360

239361
@Override
240-
public Secret call() throws RuntimeException {
362+
public AppInstallationToken call() throws RuntimeException {
241363
JenkinsJVM.checkJenkinsJVM();
242364
String[] fields = Secret.fromString(data).getPlainText().split(SEP);
243-
return Secret.fromString(generateAppInstallationToken(fields[0], fields[1], fields[2], fields[3]));
365+
LOGGER.log(Level.FINE, "Generating App Installation Token for app ID {0} for agent", fields[0]);
366+
return generateAppInstallationToken(fields[0],
367+
fields[1],
368+
fields[2],
369+
fields[3]);
244370
}
245-
246371
}
247-
248372
}
249373

250374
/**

0 commit comments

Comments
 (0)