Skip to content

Commit 8244fde

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

File tree

1 file changed

+155
-28
lines changed

1 file changed

+155
-28
lines changed

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

Lines changed: 155 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,32 @@ 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+
// Adjust the token expiration to request a new token earlier than required.
155+
// This reduces the chance that a password will be requested and then
156+
// expire in the middle of a step execution
157+
try {
158+
return appInstallationToken.getExpiresAt()
159+
.toInstant()
160+
.getEpochSecond();
161+
} catch (Exception e) {
162+
// if we fail to calculate the expiration, guess at a reasonable value.
163+
LOGGER.log(Level.WARNING,
164+
"Unable to get GitHub App installation token expiration",
165+
e);
166+
return Instant.now().getEpochSecond() + AppInstallationToken.MAXIMUM_AGE_SECONDS;
167+
}
140168
}
141169

142170
@NonNull String actualApiUri() {
@@ -149,15 +177,15 @@ static String generateAppInstallationToken(String appId, String appPrivateKey, S
149177
@NonNull
150178
@Override
151179
public Secret getPassword() {
152-
long now = System.currentTimeMillis();
153180
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;
181+
synchronized (this) {
182+
if (cachedToken == null || cachedToken.isStale()) {
183+
LOGGER.log(Level.FINE, "Generating App Installation Token for app ID {0}", appID);
184+
cachedToken = generateAppInstallationToken(appID, privateKey.getPlainText(), actualApiUri(), owner);
185+
}
186+
appInstallationToken = cachedToken.getToken();
160187
}
188+
LOGGER.log(Level.FINER, "Returned GitHub App Installation Token for app ID {0}", appID);
161189

162190
return Secret.fromString(appInstallationToken);
163191
}
@@ -171,48 +199,135 @@ public String getUsername() {
171199
return appID;
172200
}
173201

202+
static class AppInstallationToken implements Serializable {
203+
/**
204+
* {@link #getPassword()} checks that the token is still valid before returning it.
205+
* The token will not expire for at least this amount of time after it is returned.
206+
*
207+
* Using a larger value will result in longer time-to-live for the token, but also more network
208+
* calls related to getting new tokens. Setting a smaller value will result in less token generation
209+
* but runs the the risk of the token expiring while it is still being used.
210+
*
211+
* The time-to-live for the token may be less than this if the initial expiration for the token when
212+
* it is returned from GitHub is less than this.
213+
*/
214+
private final static long MINIMUM_SECONDS_UNTIL_EXPIRATION = Duration.ofMinutes(45).getSeconds();
215+
216+
/**
217+
* Any token older than this is considered stale.
218+
*
219+
* This is a back stop to ensure that, in case of unforeseen error,
220+
* expired tokens are not accidentally retained past their expiration.
221+
*/
222+
private static final long MAXIMUM_AGE_SECONDS = Duration.ofMinutes(30).getSeconds();
223+
224+
private final String token;
225+
private final long tokenStaleEpochSeconds;
226+
227+
/**
228+
* Create a AppInstallationToken instance.
229+
*
230+
* @param token the token string
231+
* @param tokenExpirationEpochSeconds the time in epoch seconds that this token will expire
232+
*/
233+
public AppInstallationToken(String token, long tokenExpirationEpochSeconds) {
234+
this(token, tokenExpirationEpochSeconds, Instant.now().getEpochSecond());
235+
}
236+
237+
238+
/**
239+
* Internal constructor for testing only.
240+
*
241+
* Use {@link #AppInstallationToken(String, long)} instead.
242+
*
243+
* @param token the token string
244+
* @param tokenExpirationEpochSeconds the time in epoch seconds that this token will expire
245+
* @param now current time in epoch seconds.
246+
*/
247+
AppInstallationToken(String token, long tokenExpirationEpochSeconds, long now) {
248+
long nextSecond = now + 1;
249+
250+
// Tokens go stale a while before they will expire
251+
long tokenStaleEpochSeconds = tokenExpirationEpochSeconds - MINIMUM_SECONDS_UNTIL_EXPIRATION;
252+
253+
// Tokens are not stale as soon as they are made
254+
if (tokenStaleEpochSeconds < nextSecond) {
255+
tokenStaleEpochSeconds = nextSecond;
256+
} else {
257+
// Tokens have a maximum age at which they go stale
258+
tokenStaleEpochSeconds = Math.min(tokenExpirationEpochSeconds, nextSecond + MAXIMUM_AGE_SECONDS);
259+
}
260+
261+
this.token = token;
262+
this.tokenStaleEpochSeconds = tokenStaleEpochSeconds;
263+
}
264+
265+
public String getToken() {
266+
return token;
267+
}
268+
269+
/**
270+
* Whether a token is stale and should be replaced with a new token.
271+
*
272+
* {@link #getPassword()} checks that the token is not "stale" before returning it.
273+
* If a token is "stale" if it has expired, exceeded {@link #MAXIMUM_AGE_SECONDS}, or
274+
* will expire in less than {@link #MINIMUM_SECONDS_UNTIL_EXPIRATION}.
275+
*
276+
* @return {@code true} if token should be refreshed, otherwise {@code false}.
277+
*/
278+
public boolean isStale() {
279+
return Instant.now().getEpochSecond() >= tokenStaleEpochSeconds;
280+
}
281+
282+
}
283+
174284
/**
175285
* Ensures that the credentials state as serialized via Remoting to an agent calls back to the controller.
176286
* Benefits:
177287
* <ul>
288+
* <li>The token is cached locally and used until it is stale.
178289
* <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.
290+
* <li>We avoid the considerable amount of class loading associated with the JWT library, Jackson data binding, Bouncy Castle, etc.
180291
* <li>The agent need not be able to contact GitHub.
181292
* </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>
186293
* @see CredentialsSnapshotTaker
187294
*/
188295
private Object writeReplace() {
189296
if (/* XStream */Channel.current() == null) {
190297
return this;
191298
}
192299
return new DelegatingGitHubAppCredentials(this);
193-
}
300+
}
194301

195302
private static final class DelegatingGitHubAppCredentials extends BaseStandardCredentials implements StandardUsernamePasswordCredentials {
196303

197-
static final String SEP = "%%%";
304+
private static final String SEP = "%%%";
198305

199306
private final String appID;
200-
private final String data;
307+
private final String tokenRefreshData;
308+
private AppInstallationToken cachedToken;
309+
201310
private transient Channel ch;
202311

203312
DelegatingGitHubAppCredentials(GitHubAppCredentials onMaster) {
204313
super(onMaster.getScope(), onMaster.getId(), onMaster.getDescription());
205314
JenkinsJVM.checkJenkinsJVM();
206315
appID = onMaster.appID;
207-
data = Secret.fromString(onMaster.appID + SEP + onMaster.privateKey.getPlainText() + SEP + onMaster.actualApiUri() + SEP + onMaster.owner).getEncryptedValue();
316+
tokenRefreshData = Secret.fromString(onMaster.appID + SEP + onMaster.privateKey.getPlainText() + SEP + onMaster.actualApiUri() + SEP + onMaster.owner).getEncryptedValue();
317+
synchronized (onMaster) {
318+
cachedToken = onMaster.cachedToken;
319+
}
208320
}
209321

210322
private Object readResolve() {
211323
JenkinsJVM.checkNotJenkinsJVM();
212-
ch = Channel.currentOrFail();
324+
synchronized (this) {
325+
ch = Channel.currentOrFail();
326+
}
213327
return this;
214328
}
215329

330+
@NonNull
216331
@Override
217332
public String getUsername() {
218333
return appID;
@@ -222,29 +337,41 @@ public String getUsername() {
222337
public Secret getPassword() {
223338
JenkinsJVM.checkNotJenkinsJVM();
224339
try {
225-
return ch.call(new GetPassword(data));
340+
String appInstallationToken;
341+
synchronized (this) {
342+
if (cachedToken == null || cachedToken.isStale()) {
343+
cachedToken = ch.call(new GetToken(tokenRefreshData));
344+
}
345+
appInstallationToken = cachedToken.getToken();
346+
}
347+
LOGGER.log(Level.FINER, "Returned GitHub App Installation Token for app ID {0} on agent", appID);
348+
349+
return Secret.fromString(appInstallationToken);
226350
} catch (IOException | InterruptedException x) {
351+
LOGGER.log(Level.WARNING, "Failed to get GitHub App Installation token on agent: " + getId(), x);
227352
throw new RuntimeException(x);
228353
}
229354
}
230355

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

233358
private final String data;
234359

235-
GetPassword(String data) {
360+
GetToken(String data) {
236361
this.data = data;
237362
}
238363

239364
@Override
240-
public Secret call() throws RuntimeException {
365+
public AppInstallationToken call() throws RuntimeException {
241366
JenkinsJVM.checkJenkinsJVM();
242367
String[] fields = Secret.fromString(data).getPlainText().split(SEP);
243-
return Secret.fromString(generateAppInstallationToken(fields[0], fields[1], fields[2], fields[3]));
368+
LOGGER.log(Level.FINE, "Generating App Installation Token for app ID {0} for agent", fields[0]);
369+
return generateAppInstallationToken(fields[0],
370+
fields[1],
371+
fields[2],
372+
fields[3]);
244373
}
245-
246374
}
247-
248375
}
249376

250377
/**

0 commit comments

Comments
 (0)