14
14
import hudson .util .ListBoxModel ;
15
15
import hudson .util .Secret ;
16
16
import java .io .IOException ;
17
+ import java .io .Serializable ;
18
+ import java .time .Duration ;
19
+ import java .time .Instant ;
17
20
import java .util .List ;
21
+ import java .util .logging .Level ;
22
+ import java .util .logging .Logger ;
23
+
18
24
import jenkins .security .SlaveToMasterCallable ;
19
25
import jenkins .util .JenkinsJVM ;
20
26
import org .kohsuke .accmod .Restricted ;
34
40
@ SuppressFBWarnings (value = "SE_NO_SERIALVERSIONID" , justification = "XStream" )
35
41
public class GitHubAppCredentials extends BaseStandardCredentials implements StandardUsernamePasswordCredentials {
36
42
43
+ private static final Logger LOGGER = Logger .getLogger (GitHubAppCredentials .class .getName ());
44
+
37
45
private static final String ERROR_AUTHENTICATING_GITHUB_APP = "Couldn't authenticate with GitHub app ID %s" ;
38
46
private static final String NOT_INSTALLED = ", has it been installed to your GitHub organisation / user?" ;
39
47
@@ -49,8 +57,7 @@ public class GitHubAppCredentials extends BaseStandardCredentials implements Sta
49
57
50
58
private String owner ;
51
59
52
- private transient String cachedToken ;
53
- private transient long tokenCacheTime ;
60
+ private transient AppInstallationToken cachedToken ;
54
61
55
62
@ DataBoundConstructor
56
63
@ SuppressWarnings ("unused" ) // by stapler
@@ -104,7 +111,7 @@ public void setOwner(String owner) {
104
111
}
105
112
106
113
@ 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 ) {
108
115
try {
109
116
String jwtToken = createJWT (appId , appPrivateKey );
110
117
GitHub gitHubApp = Connector
@@ -132,11 +139,32 @@ static String generateAppInstallationToken(String appId, String appPrivateKey, S
132
139
.createToken (appInstallation .getPermissions ())
133
140
.create ();
134
141
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 );
136
147
} catch (IOException e ) {
148
+ LOGGER .log (Level .WARNING , "Failed to retrieve GitHub App installation token for app ID " + appId , e );
137
149
throw new IllegalArgumentException (String .format (ERROR_AUTHENTICATING_GITHUB_APP , appId ), e );
138
150
}
151
+ }
139
152
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
+ }
140
168
}
141
169
142
170
@ NonNull String actualApiUri () {
@@ -149,15 +177,15 @@ static String generateAppInstallationToken(String appId, String appPrivateKey, S
149
177
@ NonNull
150
178
@ Override
151
179
public Secret getPassword () {
152
- long now = System .currentTimeMillis ();
153
180
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 () ;
160
187
}
188
+ LOGGER .log (Level .FINER , "Returned GitHub App Installation Token for app ID {0}" , appID );
161
189
162
190
return Secret .fromString (appInstallationToken );
163
191
}
@@ -171,48 +199,135 @@ public String getUsername() {
171
199
return appID ;
172
200
}
173
201
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
+
174
284
/**
175
285
* Ensures that the credentials state as serialized via Remoting to an agent calls back to the controller.
176
286
* Benefits:
177
287
* <ul>
288
+ * <li>The token is cached locally and used until it is stale.
178
289
* <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.
180
291
* <li>The agent need not be able to contact GitHub.
181
292
* </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>
186
293
* @see CredentialsSnapshotTaker
187
294
*/
188
295
private Object writeReplace () {
189
296
if (/* XStream */ Channel .current () == null ) {
190
297
return this ;
191
298
}
192
299
return new DelegatingGitHubAppCredentials (this );
193
- }
300
+ }
194
301
195
302
private static final class DelegatingGitHubAppCredentials extends BaseStandardCredentials implements StandardUsernamePasswordCredentials {
196
303
197
- static final String SEP = "%%%" ;
304
+ private static final String SEP = "%%%" ;
198
305
199
306
private final String appID ;
200
- private final String data ;
307
+ private final String tokenRefreshData ;
308
+ private AppInstallationToken cachedToken ;
309
+
201
310
private transient Channel ch ;
202
311
203
312
DelegatingGitHubAppCredentials (GitHubAppCredentials onMaster ) {
204
313
super (onMaster .getScope (), onMaster .getId (), onMaster .getDescription ());
205
314
JenkinsJVM .checkJenkinsJVM ();
206
315
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
+ }
208
320
}
209
321
210
322
private Object readResolve () {
211
323
JenkinsJVM .checkNotJenkinsJVM ();
212
- ch = Channel .currentOrFail ();
324
+ synchronized (this ) {
325
+ ch = Channel .currentOrFail ();
326
+ }
213
327
return this ;
214
328
}
215
329
330
+ @ NonNull
216
331
@ Override
217
332
public String getUsername () {
218
333
return appID ;
@@ -222,29 +337,41 @@ public String getUsername() {
222
337
public Secret getPassword () {
223
338
JenkinsJVM .checkNotJenkinsJVM ();
224
339
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 );
226
350
} catch (IOException | InterruptedException x ) {
351
+ LOGGER .log (Level .WARNING , "Failed to get GitHub App Installation token on agent: " + getId (), x );
227
352
throw new RuntimeException (x );
228
353
}
229
354
}
230
355
231
- private static final class GetPassword extends SlaveToMasterCallable <Secret , RuntimeException > {
356
+ private static final class GetToken extends SlaveToMasterCallable <AppInstallationToken , RuntimeException > {
232
357
233
358
private final String data ;
234
359
235
- GetPassword (String data ) {
360
+ GetToken (String data ) {
236
361
this .data = data ;
237
362
}
238
363
239
364
@ Override
240
- public Secret call () throws RuntimeException {
365
+ public AppInstallationToken call () throws RuntimeException {
241
366
JenkinsJVM .checkJenkinsJVM ();
242
367
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 ]);
244
373
}
245
-
246
374
}
247
-
248
375
}
249
376
250
377
/**
0 commit comments