diff --git a/pom.xml b/pom.xml index 53813d25b..498aea8b1 100644 --- a/pom.xml +++ b/pom.xml @@ -39,7 +39,7 @@ org.jenkins-ci.plugins github-api - 1.116 + 1.122 com.coravy.hudson.plugins.github @@ -53,7 +53,7 @@ io.jenkins.plugins jjwt-api - 0.11.2-3.3ae26ccdac4e + 0.11.2-5.143e44951c52 org.jenkins-ci.plugins diff --git a/src/main/java/org/jenkinsci/plugins/github_branch_source/Connector.java b/src/main/java/org/jenkinsci/plugins/github_branch_source/Connector.java index a90e607e2..fb9220096 100644 --- a/src/main/java/org/jenkinsci/plugins/github_branch_source/Connector.java +++ b/src/main/java/org/jenkinsci/plugins/github_branch_source/Connector.java @@ -80,11 +80,11 @@ import org.kohsuke.github.GitHub; import org.kohsuke.github.GitHubBuilder; import org.kohsuke.github.RateLimitHandler; +import org.kohsuke.github.authorization.ImmutableAuthorizationProvider; import org.kohsuke.github.extras.okhttp3.OkHttpConnector; import static java.util.logging.Level.FINE; import static java.util.logging.Level.WARNING; -import static org.apache.commons.lang3.StringUtils.isBlank; /** * Utilities that could perhaps be moved into {@code github-api}. @@ -340,15 +340,21 @@ public static void checkApiUrlValidity(@Nonnull GitHub gitHub, @CheckForNull Sta String apiUrl = Util.fixEmptyAndTrim(apiUri); apiUrl = apiUrl != null ? apiUrl : GitHubServerConfig.GITHUB_URL; String username; - String password; + String password = null; String hash; String authHash; + GitHubAppCredentials gitHubAppCredentials = null; Jenkins jenkins = Jenkins.get(); if (credentials == null) { username = null; password = null; hash = "anonymous"; authHash = "anonymous"; + } else if (credentials instanceof GitHubAppCredentials) { + gitHubAppCredentials = (GitHubAppCredentials) credentials; + hash = Util.getDigestOf(gitHubAppCredentials.getAppID() + gitHubAppCredentials.getOwner() + gitHubAppCredentials.getPrivateKey().getPlainText() + SALT); // want to ensure pooling by credential + authHash = Util.getDigestOf(gitHubAppCredentials.getAppID() + "::" + gitHubAppCredentials.getOwner() + "::" + gitHubAppCredentials.getPrivateKey().getPlainText() + "::" + jenkins.getLegacyInstanceId()); + username = gitHubAppCredentials.getUsername(); } else if (credentials instanceof StandardUsernamePasswordCredentials) { StandardUsernamePasswordCredentials c = (StandardUsernamePasswordCredentials) credentials; username = c.getUsername(); @@ -369,10 +375,15 @@ public static void checkApiUrlValidity(@Nonnull GitHub gitHub, @CheckForNull Sta GitHubBuilder gb = createGitHubBuilder(apiUrl, cache); - if (username != null) { - gb.withPassword(username, password); + if (gitHubAppCredentials != null) { + gb.withAuthorizationProvider(gitHubAppCredentials.getAuthorizationProvider()); + } else if (username != null && password != null) { + // At the time of this change this works for OAuth tokens as well. + // This may not continue to work in the future, as GitHub has deprecated Login/Password credentials. + gb.withAuthorizationProvider(ImmutableAuthorizationProvider.fromLoginAndPassword(username, password)); } + record = GitHubConnection.connect(connectionId, gb.build(), cache, credentials instanceof GitHubAppCredentials); } diff --git a/src/main/java/org/jenkinsci/plugins/github_branch_source/GitHubAppCredentials.java b/src/main/java/org/jenkinsci/plugins/github_branch_source/GitHubAppCredentials.java index cfba911ac..da10b6a44 100644 --- a/src/main/java/org/jenkinsci/plugins/github_branch_source/GitHubAppCredentials.java +++ b/src/main/java/org/jenkinsci/plugins/github_branch_source/GitHubAppCredentials.java @@ -15,6 +15,7 @@ import hudson.util.Secret; import java.io.IOException; import java.io.Serializable; +import java.security.GeneralSecurityException; import java.time.Duration; import java.time.Instant; import java.util.List; @@ -32,13 +33,14 @@ import org.kohsuke.github.GHAppInstallation; import org.kohsuke.github.GHAppInstallationToken; import org.kohsuke.github.GitHub; +import org.kohsuke.github.authorization.AuthorizationProvider; +import org.kohsuke.github.extras.authorization.JWTTokenProvider; import org.kohsuke.stapler.DataBoundConstructor; import org.kohsuke.stapler.DataBoundSetter; import org.kohsuke.stapler.QueryParameter; import org.kohsuke.stapler.verb.POST; import static org.jenkinsci.plugins.github_branch_source.GitHubSCMNavigator.DescriptorImpl.getPossibleApiUriItems; -import static org.jenkinsci.plugins.github_branch_source.JwtHelper.createJWT; @SuppressFBWarnings(value = "SE_NO_SERIALVERSIONID", justification = "XStream") public class GitHubAppCredentials extends BaseStandardCredentials implements StandardUsernamePasswordCredentials { @@ -122,16 +124,45 @@ public void setOwner(String owner) { this.owner = Util.fixEmpty(owner); } + @SuppressWarnings("deprecation") + AuthorizationProvider getAuthorizationProvider() { + return new TokenProvider(this); + } + + private static AuthorizationProvider createJwtProvider(String appId, String appPrivateKey) { + try { + return new JWTTokenProvider(appId, appPrivateKey); + } catch (GeneralSecurityException e) { + throw new IllegalArgumentException("Couldn't parse private key for GitHub app, make sure it's PKCS#8 format", e); + } + } + + private static class TokenProvider extends GitHub.DependentAuthorizationProvider { + private final GitHubAppCredentials credentials; + + TokenProvider(GitHubAppCredentials credentials) { + super(createJwtProvider(credentials.appID, credentials.privateKey.getPlainText())); + this.credentials = credentials; + } + + public String getEncodedAuthorization() throws IOException { + Secret token = credentials.getPassword(gitHub()); + return String.format("token %s", token.getPlainText()); + } + } + @SuppressWarnings("deprecation") // preview features are required for GitHub app integration, GitHub api adds deprecated to all preview methods - static AppInstallationToken generateAppInstallationToken(String appId, String appPrivateKey, String apiUrl, String owner) { + static AppInstallationToken generateAppInstallationToken(GitHub gitHubApp, String appId, String appPrivateKey, String apiUrl, String owner) { JenkinsJVM.checkJenkinsJVM(); // We expect this to be fast but if anything hangs in here we do not want to block indefinitely - try (Timeout timeout = Timeout.limit(30, TimeUnit.SECONDS)) { - String jwtToken = createJWT(appId, appPrivateKey); - GitHub gitHubApp = Connector - .createGitHubBuilder(apiUrl) - .withJwtToken(jwtToken) - .build(); + + try (Timeout ignored = Timeout.limit(30, TimeUnit.SECONDS)) { + if (gitHubApp == null) { + gitHubApp = Connector + .createGitHubBuilder(apiUrl) + .withAuthorizationProvider(createJwtProvider(appId, appPrivateKey)) + .build(); + } GHApp app; try { @@ -151,7 +182,8 @@ static AppInstallationToken generateAppInstallationToken(String appId, String ap appInstallation = appInstallations.stream() .filter(installation -> installation.getAccount().getLogin().equals(owner)) .findAny() - .orElseThrow(() -> new IllegalArgumentException(String.format(ERROR_NOT_INSTALLED, appId))); + .orElseThrow(() -> new IllegalArgumentException(String.format(ERROR_NOT_INSTALLED, + appId))); } GHAppInstallationToken appInstallationToken = appInstallation @@ -198,17 +230,14 @@ private static long getExpirationSeconds(GHAppInstallationToken appInstallationT return Util.fixEmpty(apiUri) == null ? "https://api.github.com" : apiUri; } - /** - * {@inheritDoc} - */ - @NonNull - @Override - public Secret getPassword() { + private Secret getPassword(GitHub gitHub) { synchronized (this) { try { if (cachedToken == null || cachedToken.isStale()) { LOGGER.log(Level.FINE, "Generating App Installation Token for app ID {0}", appID); - cachedToken = generateAppInstallationToken(appID, + cachedToken = generateAppInstallationToken( + gitHub, + appID, privateKey.getPlainText(), actualApiUri(), owner); @@ -230,7 +259,15 @@ public Secret getPassword() { return cachedToken.getToken(); } + } + /** + * {@inheritDoc} + */ + @NonNull + @Override + public Secret getPassword() { + return this.getPassword(null); } /** @@ -442,7 +479,7 @@ public Secret getPassword() { // while only slightly increasing the chance that tokens will expire while in use. LOGGER.log(Level.WARNING, "Failed to generate new GitHub App Installation Token for app ID " + appID + " on agent: cached token is stale but has not expired"); - // Logging the exception here caused a security exeception when trying to read the agent logs during testing + // Logging the exception here caused a security exception when trying to read the agent logs during testing // Added the exception to a secondary log message that can be viewed if it is needed LOGGER.log(Level.FINER, () -> Functions.printThrowable(e)); } else { @@ -473,6 +510,7 @@ public AppInstallationToken call() throws RuntimeException { JSONObject fields = JSONObject.fromObject(Secret.fromString(data).getPlainText()); LOGGER.log(Level.FINE, "Generating App Installation Token for app ID {0} for agent", fields.get("appID")); AppInstallationToken token = generateAppInstallationToken( + null, (String)fields.get("appID"), (String)fields.get("privateKey"), (String)fields.get("apiUri"), diff --git a/src/main/java/org/jenkinsci/plugins/github_branch_source/JwtHelper.java b/src/main/java/org/jenkinsci/plugins/github_branch_source/JwtHelper.java deleted file mode 100644 index 53cf893b9..000000000 --- a/src/main/java/org/jenkinsci/plugins/github_branch_source/JwtHelper.java +++ /dev/null @@ -1,88 +0,0 @@ -package org.jenkinsci.plugins.github_branch_source; - -import io.jsonwebtoken.JwtBuilder; -import io.jsonwebtoken.Jwts; -import io.jsonwebtoken.SignatureAlgorithm; -import java.security.GeneralSecurityException; -import java.security.Key; -import java.security.KeyFactory; -import java.security.PrivateKey; -import java.security.spec.PKCS8EncodedKeySpec; -import java.util.Base64; -import java.util.Date; - -import static java.util.Objects.requireNonNull; -import java.util.concurrent.TimeUnit; - -class JwtHelper { - - /** - * Somewhat less than the maximum JWT validity. - */ - static final long VALIDITY_MS = TimeUnit.MINUTES.toMillis(8); - - /** - * Create a JWT for authenticating to GitHub as an app installation - * @param githubAppId the app ID - * @param privateKey PKC#8 formatted private key - * @return JWT for authenticating to GitHub - */ - static String createJWT(String githubAppId, final String privateKey) { - requireNonNull(githubAppId, privateKey); - - SignatureAlgorithm signatureAlgorithm = SignatureAlgorithm.RS256; - - long nowMillis = System.currentTimeMillis(); - Date now = new Date(nowMillis); - - Key signingKey; - try { - signingKey = getPrivateKeyFromString(privateKey); - } catch (GeneralSecurityException e) { - throw new IllegalArgumentException("Couldn't parse private key for GitHub app, make sure it's PKCS#8 format", e); - } - - JwtBuilder builder = Jwts.builder() - .setIssuedAt(now) - .setIssuer(githubAppId) - .signWith(signingKey, signatureAlgorithm); - - Date exp = new Date(nowMillis + VALIDITY_MS); - builder.setExpiration(exp); - - return builder.compact(); - } - - /** - * Convert a PKCS#8 formatted private key in string format into a java PrivateKey - * @param key PCKS#8 string - * @return private key - * @throws GeneralSecurityException if we couldn't parse the string - */ - private static PrivateKey getPrivateKeyFromString(final String key) throws GeneralSecurityException { - if (key.contains(" RSA ")) { - throw new InvalidPrivateKeyException( - "Private key must be a PKCS#8 formatted string, to convert it from PKCS#1 use: " - + "openssl pkcs8 -topk8 -inform PEM -outform PEM -in current-key.pem -out new-key.pem -nocrypt" - ); - } - - String privateKeyContent = key - .replaceAll("\\n", "") - .replaceAll("\\r", "") - .replace("-----BEGIN PRIVATE KEY-----", "") - .replace("-----END PRIVATE KEY-----", ""); - - KeyFactory kf = KeyFactory.getInstance("RSA"); - - try { - byte[] decode = Base64.getDecoder().decode(privateKeyContent); - PKCS8EncodedKeySpec keySpecPKCS8 = new PKCS8EncodedKeySpec(decode); - - return kf.generatePrivate(keySpecPKCS8); - } catch (IllegalArgumentException e) { - throw new InvalidPrivateKeyException("Failed to decode private key: " + e.getMessage()); - } - } - -} diff --git a/src/test/java/org/jenkinsci/plugins/github_branch_source/GithubAppCredentialsTest.java b/src/test/java/org/jenkinsci/plugins/github_branch_source/GithubAppCredentialsTest.java index a7d930bd3..23a0afb7a 100644 --- a/src/test/java/org/jenkinsci/plugins/github_branch_source/GithubAppCredentialsTest.java +++ b/src/test/java/org/jenkinsci/plugins/github_branch_source/GithubAppCredentialsTest.java @@ -20,6 +20,8 @@ import org.junit.Rule; import org.junit.Test; import org.jvnet.hudson.test.JenkinsRule; +import org.kohsuke.github.GitHub; +import org.kohsuke.github.authorization.AuthorizationProvider; import java.time.Duration; import java.time.Instant; @@ -37,6 +39,7 @@ import static com.github.tomakehurst.wiremock.client.WireMock.*; import static org.hamcrest.Matchers.*; +import static org.jenkinsci.plugins.github_branch_source.Connector.createGitHubBuilder; public class GithubAppCredentialsTest extends AbstractGitHubWireMockTest { @@ -46,6 +49,38 @@ public class GithubAppCredentialsTest extends AbstractGitHubWireMockTest { private static GitHubAppCredentials appCredentials; private static LogRecorder logRecorder; + // https://stackoverflow.com/a/22176759/4951015 + public static final String PKCS8_PRIVATE_KEY = "-----BEGIN PRIVATE KEY-----\n" + + // Windows line ending + "MIIEvgIBADANBgkqhkiG9w0BAQEFAASCBKgwggSkAgEAAoIBAQD7vHsVwyDV8cj7\r\n" + + // This should also work + "5yR4WWl6rlgf/e5zmeBgtm0PCgnitcSbD5FU33301DPY5a7AtqVBOwEnE14L9XS7\r" + + "ov61U+x1m4aQmqR/dPQaA2ayh2cYPszWNQMp42ArDIfg7DhSrvsRJKHsbPXlPjqe\n" + + "c0udLqhSLVIO9frNLf+dAsLsgYk8O39PKGb33akGG7tWTe0J+akNQjgbS7vOi8sS\n" + + "NLwHIdYfz/Am+6Xmm+J4yVs6+Xt3kOeLdFBkz8H/HGsJq854MbIAK/HuId1MOPS0\n" + + "cDWh37tzRsM+q/HZzYRkc5bhNKw/Mj9jN9jD5GH0Lfea0QFedjppf1KvWdcXn+/W\n" + + "M7OmyfhvAgMBAAECggEAN96H7reExRbJRWbySCeH6mthMZB46H0hODWklK7krMUs\n" + + "okFdPtnvKXQjIaMwGqMuoACJa/O3bq4GP1KYdwPuOdfPkK5RjdwWBOP2We8FKXNe\n" + + "oLfZQOWuxT8dtQSYJ3mgTRi1OzSfikY6Wko6YOMnBj36tUlQZVMtJNqlCjphi9Uz\n" + + "6EyvRURlDG8sBBbC7ods5B0789qk3iGH/97ia+1QIqXAUaVFg3/BA6wkxkbNG2sN\n" + + "tqULgVYTw32Oj/Y/H1Y250RoocTyfsUS3I3aPIlnvcgp2bugWqDyYJ58nDIt3Pku\n" + + "fjImWrNz/pNiEs+efnb0QEk7m5hYwxmyXN4KRSv0OQKBgQD+I3Y3iNKSVr6wXjur\n" + + "OPp45fxS2sEf5FyFYOn3u760sdJOH9fGlmf9sDozJ8Y8KCaQCN5tSe3OM+XDrmiw\n" + + "Cu/oaqJ1+G4RG+6w1RJF+5Nfg6PkUs7eJehUgZ2Tox8Tg1mfVIV8KbMwNi5tXpug\n" + + "MVmA2k9xjc4uMd2jSnSj9NAqrQKBgQD9lIO1tY6YKF0Eb0Qi/iLN4UqBdJfnALBR\n" + + "MjxYxqqI8G4wZEoZEJJvT1Lm6Q3o577N95SihZoj69tb10vvbEz1pb3df7c1HEku\n" + + "LXcyVMvjR/CZ7dOSNgLGAkFfOoPhcF/OjSm4DrGPe3GiBxhwXTBjwJ5TIgEDkVIx\n" + + "ZVo5r7gPCwKBgQCOvsZo/Q4hql2jXNqxGuj9PVkUBNFTI4agWEYyox7ECdlxjks5\n" + + "vUOd5/1YvG+JXJgEcSbWRh8volDdL7qXnx0P881a6/aO35ybcKK58kvd62gEGEsf\n" + + "1jUAOmmTAp2y7SVK7EOp8RY370b2oZxSR0XZrUXQJ3F22wV98ZVAfoLqZQKBgDIr\n" + + "PdunbezAn5aPBOX/bZdZ6UmvbZYwVrHZxIKz2214U/STAu3uj2oiQX6ZwTzBDMjn\n" + + "IKr+z74nnaCP+eAGhztabTPzXqXNUNUn/Zshl60BwKJToTYeJXJTY+eZRhpGB05w\n" + + "Mz7M+Wgvvg2WZcllRnuV0j0UTysLhz1qle0vzLR9AoGBAOukkFFm2RLm9N1P3gI8\n" + + "mUadeAlYRZ5o0MvumOHaB5pDOCKhrqAhop2gnM0f5uSlapCtlhj0Js7ZyS3Giezg\n" + + "38oqAhAYxy2LMoLD7UtsHXNp0OnZ22djcDwh+Wp2YORm7h71yOM0NsYubGbp+CmT\n" + + "Nw9bewRvqjySBlDJ9/aNSeEY\n" + + "-----END PRIVATE KEY-----"; + @Rule public GitSampleRepoRule sampleRepo = new GitSampleRepoRule(); @@ -54,7 +89,7 @@ public static void setUpJenkins() throws Exception { //Add credential (Must have valid private key for Jwt to work, but App doesn't have to actually exist) store = CredentialsProvider.lookupStores(r.jenkins).iterator().next(); appCredentials = new GitHubAppCredentials( - CredentialsScope.GLOBAL, myAppCredentialsId, "sample", "54321", Secret.fromString(JwtHelperTest.PKCS8_PRIVATE_KEY)); + CredentialsScope.GLOBAL, myAppCredentialsId, "sample", "54321", Secret.fromString(PKCS8_PRIVATE_KEY)); appCredentials.setOwner("cloudbeers"); store.addCredentials(Domain.global(), appCredentials); @@ -209,6 +244,66 @@ public void setUpWireMock() throws Exception { "}"))); } + @Test + public void testProviderRefresh() throws Exception { + long notStaleSeconds = GitHubAppCredentials.AppInstallationToken.NOT_STALE_MINIMUM_SECONDS; + try { + appCredentials.setApiUri(githubApi.baseUrl()); + + // We want to demonstrate successful caching without waiting for the default 1 minute + // Must set this to a large enough number to avoid flaky test + GitHubAppCredentials.AppInstallationToken.NOT_STALE_MINIMUM_SECONDS = 5; + + // Ensure we are working from sufficiently clean cache state + Thread.sleep(Duration.ofSeconds(GitHubAppCredentials.AppInstallationToken.NOT_STALE_MINIMUM_SECONDS + 2).toMillis()); + + AuthorizationProvider provider = appCredentials.getAuthorizationProvider(); + GitHub githubInstance = createGitHubBuilder(githubApi.baseUrl()) + .withAuthorizationProvider(provider).build(); + + // First Checkout on controller should use cached + provider.getEncodedAuthorization(); + // Multiple checkouts in quick succession should use cached token + provider.getEncodedAuthorization(); + Thread.sleep(Duration.ofSeconds(GitHubAppCredentials.AppInstallationToken.NOT_STALE_MINIMUM_SECONDS + 2).toMillis()); + // Checkout after token is stale refreshes - fallback due to unexpired token + provider.getEncodedAuthorization(); + // Checkout after error will refresh again on controller - new token expired but not stale + provider.getEncodedAuthorization(); + Thread.sleep(Duration.ofSeconds(GitHubAppCredentials.AppInstallationToken.NOT_STALE_MINIMUM_SECONDS + 2).toMillis()); + // Checkout after token is stale refreshes - error on controller is not catastrophic + provider.getEncodedAuthorization(); + // Checkout after error will refresh again on controller - new token expired but not stale + provider.getEncodedAuthorization(); + // Multiple checkouts in quick succession should use cached token + provider.getEncodedAuthorization(); + + List credentialsLog = getOutputLines(); + + //Verify correct messages from GitHubAppCredential logger indicating token was retrieved on agent + assertThat("Creds should cache on master", + credentialsLog, contains( + // refresh on controller + "Generating App Installation Token for app ID 54321", + // next call uses cached token + // sleep and then refresh stale token + "Generating App Installation Token for app ID 54321", + // next call (error forced by wiremock) + "Failed to generate new GitHub App Installation Token for app ID 54321: cached token is stale but has not expired", + // next call refreshes the still stale token + "Generating App Installation Token for app ID 54321", + // sleep and then refresh stale token hits another error forced by wiremock + "Failed to generate new GitHub App Installation Token for app ID 54321: cached token is stale but has not expired", + // next call refreshes the still stale token + "Generating App Installation Token for app ID 54321" + // next call uses cached token + )); + } finally { + GitHubAppCredentials.AppInstallationToken.NOT_STALE_MINIMUM_SECONDS = notStaleSeconds; + logRecorder.doClear(); + } + } + @Test public void testAgentRefresh() throws Exception { long notStaleSeconds = GitHubAppCredentials.AppInstallationToken.NOT_STALE_MINIMUM_SECONDS; @@ -219,6 +314,9 @@ public void testAgentRefresh() throws Exception { // Must set this to a large enough number to avoid flaky test GitHubAppCredentials.AppInstallationToken.NOT_STALE_MINIMUM_SECONDS = 5; + // Ensure we are working from sufficiently clean cache state + Thread.sleep(Duration.ofSeconds(GitHubAppCredentials.AppInstallationToken.NOT_STALE_MINIMUM_SECONDS + 2).toMillis()); + final String gitCheckoutStep = String.format( " git url: REPO, credentialsId: '%s'", myAppCredentialsId); @@ -261,7 +359,6 @@ public void testAgentRefresh() throws Exception { r.waitUntilNoActivity(); System.out.println(JenkinsRule.getLog(run)); - assertThat(run.getResult(), equalTo(Result.SUCCESS)); List credentialsLog = getOutputLines(); @@ -300,6 +397,10 @@ credentialsLog, contains( // checkout scm // (No token generation) )); + + // Check success after output. Output will be more informative if something goes wrong. + assertThat(run.getResult(), equalTo(Result.SUCCESS)); + } finally { GitHubAppCredentials.AppInstallationToken.NOT_STALE_MINIMUM_SECONDS = notStaleSeconds; logRecorder.doClear(); @@ -324,4 +425,4 @@ static String printDate(Date dt) { ChronoUnit.SECONDS)); } -} \ No newline at end of file +} diff --git a/src/test/java/org/jenkinsci/plugins/github_branch_source/JwtHelperTest.java b/src/test/java/org/jenkinsci/plugins/github_branch_source/JwtHelperTest.java deleted file mode 100644 index d63381dda..000000000 --- a/src/test/java/org/jenkinsci/plugins/github_branch_source/JwtHelperTest.java +++ /dev/null @@ -1,178 +0,0 @@ -package org.jenkinsci.plugins.github_branch_source; - -import io.jsonwebtoken.Claims; -import io.jsonwebtoken.Jws; -import io.jsonwebtoken.Jwts; -import java.security.GeneralSecurityException; -import java.security.KeyFactory; -import java.security.PublicKey; -import java.security.spec.X509EncodedKeySpec; -import java.util.Base64; -import org.junit.Rule; -import org.junit.Test; -import org.junit.rules.ExpectedException; - -import static org.hamcrest.MatcherAssert.assertThat; -import static org.hamcrest.core.Is.is; -import static org.jenkinsci.plugins.github_branch_source.JwtHelper.createJWT; -import static org.mockito.ArgumentMatchers.contains; - -public class JwtHelperTest { - - @Rule - public ExpectedException expectedException = ExpectedException.none(); - - // https://stackoverflow.com/a/22176759/4951015 - public static final String PKCS8_PRIVATE_KEY = "-----BEGIN PRIVATE KEY-----\n" + - // Windows line ending - "MIIEvgIBADANBgkqhkiG9w0BAQEFAASCBKgwggSkAgEAAoIBAQD7vHsVwyDV8cj7\r\n" + - // This should also work - "5yR4WWl6rlgf/e5zmeBgtm0PCgnitcSbD5FU33301DPY5a7AtqVBOwEnE14L9XS7\r" + - "ov61U+x1m4aQmqR/dPQaA2ayh2cYPszWNQMp42ArDIfg7DhSrvsRJKHsbPXlPjqe\n" + - "c0udLqhSLVIO9frNLf+dAsLsgYk8O39PKGb33akGG7tWTe0J+akNQjgbS7vOi8sS\n" + - "NLwHIdYfz/Am+6Xmm+J4yVs6+Xt3kOeLdFBkz8H/HGsJq854MbIAK/HuId1MOPS0\n" + - "cDWh37tzRsM+q/HZzYRkc5bhNKw/Mj9jN9jD5GH0Lfea0QFedjppf1KvWdcXn+/W\n" + - "M7OmyfhvAgMBAAECggEAN96H7reExRbJRWbySCeH6mthMZB46H0hODWklK7krMUs\n" + - "okFdPtnvKXQjIaMwGqMuoACJa/O3bq4GP1KYdwPuOdfPkK5RjdwWBOP2We8FKXNe\n" + - "oLfZQOWuxT8dtQSYJ3mgTRi1OzSfikY6Wko6YOMnBj36tUlQZVMtJNqlCjphi9Uz\n" + - "6EyvRURlDG8sBBbC7ods5B0789qk3iGH/97ia+1QIqXAUaVFg3/BA6wkxkbNG2sN\n" + - "tqULgVYTw32Oj/Y/H1Y250RoocTyfsUS3I3aPIlnvcgp2bugWqDyYJ58nDIt3Pku\n" + - "fjImWrNz/pNiEs+efnb0QEk7m5hYwxmyXN4KRSv0OQKBgQD+I3Y3iNKSVr6wXjur\n" + - "OPp45fxS2sEf5FyFYOn3u760sdJOH9fGlmf9sDozJ8Y8KCaQCN5tSe3OM+XDrmiw\n" + - "Cu/oaqJ1+G4RG+6w1RJF+5Nfg6PkUs7eJehUgZ2Tox8Tg1mfVIV8KbMwNi5tXpug\n" + - "MVmA2k9xjc4uMd2jSnSj9NAqrQKBgQD9lIO1tY6YKF0Eb0Qi/iLN4UqBdJfnALBR\n" + - "MjxYxqqI8G4wZEoZEJJvT1Lm6Q3o577N95SihZoj69tb10vvbEz1pb3df7c1HEku\n" + - "LXcyVMvjR/CZ7dOSNgLGAkFfOoPhcF/OjSm4DrGPe3GiBxhwXTBjwJ5TIgEDkVIx\n" + - "ZVo5r7gPCwKBgQCOvsZo/Q4hql2jXNqxGuj9PVkUBNFTI4agWEYyox7ECdlxjks5\n" + - "vUOd5/1YvG+JXJgEcSbWRh8volDdL7qXnx0P881a6/aO35ybcKK58kvd62gEGEsf\n" + - "1jUAOmmTAp2y7SVK7EOp8RY370b2oZxSR0XZrUXQJ3F22wV98ZVAfoLqZQKBgDIr\n" + - "PdunbezAn5aPBOX/bZdZ6UmvbZYwVrHZxIKz2214U/STAu3uj2oiQX6ZwTzBDMjn\n" + - "IKr+z74nnaCP+eAGhztabTPzXqXNUNUn/Zshl60BwKJToTYeJXJTY+eZRhpGB05w\n" + - "Mz7M+Wgvvg2WZcllRnuV0j0UTysLhz1qle0vzLR9AoGBAOukkFFm2RLm9N1P3gI8\n" + - "mUadeAlYRZ5o0MvumOHaB5pDOCKhrqAhop2gnM0f5uSlapCtlhj0Js7ZyS3Giezg\n" + - "38oqAhAYxy2LMoLD7UtsHXNp0OnZ22djcDwh+Wp2YORm7h71yOM0NsYubGbp+CmT\n" + - "Nw9bewRvqjySBlDJ9/aNSeEY\n" + - "-----END PRIVATE KEY-----"; - - public static final String PKCS8_PUBLIC_KEY = "-----BEGIN PUBLIC KEY-----\n" + - "MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA+7x7FcMg1fHI++ckeFlp\n" + - "eq5YH/3uc5ngYLZtDwoJ4rXEmw+RVN999NQz2OWuwLalQTsBJxNeC/V0u6L+tVPs\n" + - "dZuGkJqkf3T0GgNmsodnGD7M1jUDKeNgKwyH4Ow4Uq77ESSh7Gz15T46nnNLnS6o\n" + - "Ui1SDvX6zS3/nQLC7IGJPDt/Tyhm992pBhu7Vk3tCfmpDUI4G0u7zovLEjS8ByHW\n" + - "H8/wJvul5pvieMlbOvl7d5Dni3RQZM/B/xxrCavOeDGyACvx7iHdTDj0tHA1od+7\n" + - "c0bDPqvx2c2EZHOW4TSsPzI/YzfYw+Rh9C33mtEBXnY6aX9Sr1nXF5/v1jOzpsn4\n" + - "bwIDAQAB\n" + - "-----END PUBLIC KEY-----"; - - - private static final String PKCS1_PRIVATE_KEY = "-----BEGIN RSA PRIVATE KEY-----\n" + - "MIIEpAIBAAKCAQEA26y2ZLYaNKHYg1FehH/WmXZ+SXG9ofLCf7+tR0j/BHbQy1Ck\n" + - "u6Pqxn10nKPrAZSFakNDKI1vf92+Ny8LFitBucs2JaDSm1kUHjZaoCbp2FQmbr28\n" + - "eO+q0oIaJ67WaIF9o1DzCiBBgqCOqZpDdZY1peRPQ7ttBfBvPOi9zEiWplrn2IlL\n" + - "tlndlYtV+KHlIy7odaCaSHjzawTBxLe82lpX5+YHy0doNlI5l/epJMtjcE/l2jEj\n" + - "xMZxWz4ZAiXd8hLYonUzxaup8IMKm4K8eh++4UcXAs0tjA0CGaieeyQZyBLPwFyf\n" + - "k3JStqbBgwaKLzV0D1ayokQNvc0cm4tdgk6gVwIDAQABAoIBAGlZzSdDhhHTxIhF\n" + - "z7RvsrVqdGo4mB9A0zJ89FcJlPPJH51CEZ7Dn+aNaA1vN1dMqScrFtwt6FlEOOMy\n" + - "NnjtSdoWsOMe26IQ+Gr82j2QK/nJcZ0OdYLyPdQy/OQnH0CDSYO3YLdsfL5uzbxc\n" + - "9RlBbn0enzz2d/SvOEnXvJ5p+YXRk3Y8Toccu66nPUKkeWDzZ3Ql/mf2Piw1VwvF\n" + - "/5pvZRiH5Lh5MCc7AxHlDFXRq5jQKxSdJrtHhB/GFRfHg6EOAKfCGbPHwYIMb5BW\n" + - "KNxRRyfpAPhUP9a+GgH4mHXkv+wSR87zE3hbCf7Fg/4mB25Cx4r/34E5W0F0XuCN\n" + - "HzSwXHECgYEA8pdeT6R2mlWDgD7IfhyeYoUcJ0oXvd6dKlGOETlkzkGi4QvP3BsM\n" + - "wg0sELPhuYCOG53SzSW9d5QkqDYJRY4/xg15QV2LYOMpP5b9cjJZRE3Uo9BVIBum\n" + - "EFVZvuGzZaFUO6Zx3xQiQgHuCP8Tx676vTk36ka3fVQV5FdY8tP0HyUCgYEA59ET\n" + - "v6eE2s10T9JeO3htK5TjwioMYpp3j+HUZX78anyqWw17OUityWi/dRnCoyfpPuIi\n" + - "qBGNjMk3JZYz3MmoR9pPGKgzI43EQKBay6+CjZfcQ4Vw7qzW0bUKD2xfLU+ZOeR+\n" + - "jJn5wdBvZHooX8e1en/aLj5h9h9FzhAy3/Sd1ssCgYB1S8tGJvdR2FclAzZeA+hx\n" + - "KntaY/Dm1WSYuaY/ncioEgR3XAa9Hjck/Ml5qgBSeV487CqpFr5tuyueScJh503e\n" + - "rVUbzec+iZfAL3mMZdvTsu5F5s3CIJxC+YHTUb40PbVEwk381vdZgyVdJDikLG8A\n" + - "X1Ix7M97wdRz++f+QY2gIQKBgQCeHaiHt95RU4O7EjT+AVUNPd/fxsht1QgqFpHF\n" + - "rMjEZUXZFyfuWZlX4F9+otR0bruUDbAvzNEsru4zb/Dt7ooegFQk8Ez5OjAbGIT1\n" + - "mz/EDknJsFHoKfHYVdCH1pZQlJNhvm1mv3twbBgeg4fYVKJ+7IfHtPsiYhA9ziS1\n" + - "RucF4wKBgQDJfd1BxBdkeSRIJ/C75iZ4vWWsM/JvMI1L68ZJEWdTqUvyyy9xLWEe\n" + - "8wIGZTv/mnuQhOGSaUUk0fTup7ZwTfmg+hahhCBe5kSh4bav5+knu6yQ7nhwccs8\n" + - "WXeajzno43UHZksae1LP1B3J1+0adxpykCMzWl19XZkxtVkYVi0Q3g==\n" + - "-----END RSA PRIVATE KEY-----"; - - private static final String PKCS8_PRIVATE_KEY_CONTAINING_RSA_CHARS = "-----BEGIN PRIVATE KEY-----\n" - + "MIIEvgIBADANBgkqhkiG9w0BAQEFAASCBKgwggSkAgEAAoIBAQDGKsPHpCMqBVUw\n" - + "BBxD33j/WShTVB0vdmu+SqgZeNULSy0+ipBaDRgTbyhB0mA2l9M24mH7B/vY0Cjn\n" - + "rN+42bV92fMFQRCvxgQ9cRsi2bgL4XOgSdJB9cH/GWev04kUqWRihjm5quPzp62z\n" - + "RPmNoSbrbLWVtjcfAYOSDA1SP4XBdRaWW8CjQU7VB73RGacJp7mODvi1gqccsWK6\n" - + "Qiu6fbGjTeimrWTnEPFFGdsvKgLrGqKyZBGBj9uKKC5o+l4HXvSeKSUrYJbt0bWs\n" - + "uOeirDqQ2y2QY1OxoiXGH2N5FVIJ/opVyOq/LyGgVoOSqjUzmRW2DuZT7LQm/Gl9\n" - + "EmhuEUBfAgMBAAECggEBAJWRx41SpLvdpIuGPrM348KPT7F9Rj4BmpbZEIGRQvOw\n" - + "PSj8OrHNOkPI3VC48aei9mdxfNSVFRBzJLygLYf+wk6IBzYLAwY4ZhDd4sZuH8zP\n" - + "0I7FyS3ByTe6vBjoh4mRxNPcTYuGoWDRSXiKcfTlElQVDAVAr9/2K5E7CX7vtQvq\n" - + "+R3Pi+5HGv88FxbRn7uh4PQDUBvArvJdLZVSLiq2EBkmwHGa4DZSoqm5mG8Y5W1B\n" - + "usDU5YSQhqd0t7RPeOh7VV7p0mnF9Pn0Kmc3e182N05spOzhOmP8nm9b9eGecPWE\n" - + "fqqdmWBuellCHTe0VGUBXHUCUFcItt/6xaZvwaSzJzkCgYEA5YsgIJkiy7sxqqTD\n" - + "QZLmfS4Zr2VCLlEtdBaJG6FIrKkq7YJ3njL0CyFQ4qT7Nba1uiLYL6wH3O2MaVvD\n" - + "JqRJbWfJjhcmNGnsMesFAZid12shLq5oB1Ptg2VRnNcTzdLY30MF5JIHA8YUV8/s\n" - + "xWl5PQ/L08rgHILNMpndSHee8LsCgYEA3QHaCM+N78GV03r1Rw1S9ggcJHmNa8C2\n" - + "tUSVzkIE2RgOZ+PJCt9ij0OYxD6DDdLZDd9OUw2tvMqZoag2TeprP6qtu0HKSEQj\n" - + "7YjA5BMAWqqyn8RXada0swr/y3ARKe+1haZKKzS/7qeNjD8GGTQp+NvtOgQ5DfJl\n" - + "hDDolKOhlq0CgYEAnsPQr9tbXtCV9LJLPwKtGy4Uo+UElmadaqrfoFW4n3vObkKM\n" - + "G8agV0Zu3KRCAI/kN9876hUxxxQixwip/QMqqlpb5USLrzsIHCqy5ry5h7LYW6JT\n" - + "36WkJPqiLTnxv62zRRDldYevBGQv0+DDonNmYN6ZG186DV5HMVWM4T+jllsCgYAe\n" - + "iEj0+qejPd1TECOeo0qYztoEd/5/qmoTdNw1WI2O6HHlDGUT6XSWUkJiqjg0yrJN\n" - + "5lHNy4/7CwpaeQC3lvEmJJBH1Hj7rt4/zKrJV46u9/IhfGCPMKhaK+TW2C6m2oT7\n" - + "Z9PLUEhL0j4N6A8RoFFEHi4R289+C8TWlGMtVcXXKQKBgEd1zPh3okoOEBLWBswc\n" - + "rYeW6a5YMUlWdjv3AaVGWvXCBVQV/nHLbHePDw8/Lj71+tW+aJP3/RSA38T1EbJc\n" - + "NbI/fRyHodR+Cf7jCm8DWg/lORzzGSA7FGZyV8M/AW4WQ/NjFuHme4Dg7qULEuk+\n" - + "CK428WLq7hhesv0p8tsmJP+n\n" - + "-----END PRIVATE KEY-----"; - - private static final String PKCS8_PUBLIC_KEY_CONTAINING_RSA_CHARS = "-----BEGIN PUBLIC KEY-----\n" - + "MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAxirDx6QjKgVVMAQcQ994\n" - + "/1koU1QdL3ZrvkqoGXjVC0stPoqQWg0YE28oQdJgNpfTNuJh+wf72NAo56zfuNm1\n" - + "fdnzBUEQr8YEPXEbItm4C+FzoEnSQfXB/xlnr9OJFKlkYoY5uarj86ets0T5jaEm\n" - + "62y1lbY3HwGDkgwNUj+FwXUWllvAo0FO1Qe90RmnCae5jg74tYKnHLFiukIrun2x\n" - + "o03opq1k5xDxRRnbLyoC6xqismQRgY/biiguaPpeB170niklK2CW7dG1rLjnoqw6\n" - + "kNstkGNTsaIlxh9jeRVSCf6KVcjqvy8hoFaDkqo1M5kVtg7mU+y0JvxpfRJobhFA\n" - + "XwIDAQAB\n" - + "-----END PUBLIC KEY-----"; - - @Test - public void createJWT_is_valid() throws Exception { - String jwt = createJWT("123", PKCS8_PRIVATE_KEY); - Jws parsedJwt = Jwts.parser() - .setSigningKey(getPublicKeyFromString(PKCS8_PUBLIC_KEY)) - .parseClaimsJws(jwt); - assertThat(parsedJwt.getBody().getIssuer(), is("123")); - } - - @Test - public void createJWT_with_key_containing_RSA_is_valid() throws Exception { - String jwt = createJWT("123", PKCS8_PRIVATE_KEY_CONTAINING_RSA_CHARS); - Jws parsedJwt = Jwts.parser() - .setSigningKey(getPublicKeyFromString(PKCS8_PUBLIC_KEY_CONTAINING_RSA_CHARS)) - .parseClaimsJws(jwt); - assertThat(parsedJwt.getBody().getIssuer(), is("123")); - } - - @Test - public void createJWT_with_pkcs1_is_invalid() { - expectedException.expect(InvalidPrivateKeyException.class); - expectedException.expectMessage(contains("openssl pkcs8 -topk8")); - createJWT("123", PKCS1_PRIVATE_KEY); - } - - @Test - public void createJWT_with_not_base64_is_invalid() { - expectedException.expect(InvalidPrivateKeyException.class); - expectedException.expectMessage(contains("Failed to decode private key")); - createJWT("123", "d£!@!@£!@£"); - } - - private static PublicKey getPublicKeyFromString(final String key) throws GeneralSecurityException { - String publicKeyContent = key.replaceAll("\\n", "") - .replace("-----BEGIN PUBLIC KEY-----", "") - .replace("-----END PUBLIC KEY-----", ""); - - KeyFactory kf = KeyFactory.getInstance("RSA"); - - X509EncodedKeySpec keySpecPKCS8 = new X509EncodedKeySpec(Base64.getDecoder().decode(publicKeyContent)); - - return kf.generatePublic(keySpecPKCS8); - } -}