1414import hudson .util .ListBoxModel ;
1515import hudson .util .Secret ;
1616import java .io .IOException ;
17+ import java .io .Serializable ;
18+ import java .time .Duration ;
19+ import java .time .Instant ;
1720import java .util .List ;
21+ import java .util .logging .Level ;
22+ import java .util .logging .Logger ;
23+
1824import jenkins .security .SlaveToMasterCallable ;
1925import jenkins .util .JenkinsJVM ;
2026import org .kohsuke .accmod .Restricted ;
3440@ SuppressFBWarnings (value = "SE_NO_SERIALVERSIONID" , justification = "XStream" )
3541public 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