From a277a71a9f75eed0a37f04cd1f35172f5696d13e Mon Sep 17 00:00:00 2001 From: Tim Jacomb Date: Tue, 5 Nov 2019 16:53:44 +0000 Subject: [PATCH 01/18] Add support for GitHub app authentication --- pom.xml | 20 +++ .../github_branch_source/Connector.java | 69 +++++++++- .../GitHubSCMNavigator.java | 4 +- .../InvalidPrivateKeyException.java | 8 ++ .../github_branch_source/JwtHelper.java | 82 +++++++++++ .../help-credentialsId.html | 15 +- .../GitHubSCMSource/help-credentialsId.html | 11 ++ .../github_branch_source/JwtHelperTest.java | 128 ++++++++++++++++++ 8 files changed, 324 insertions(+), 13 deletions(-) create mode 100644 src/main/java/org/jenkinsci/plugins/github_branch_source/InvalidPrivateKeyException.java create mode 100644 src/main/java/org/jenkinsci/plugins/github_branch_source/JwtHelper.java create mode 100644 src/test/java/org/jenkinsci/plugins/github_branch_source/JwtHelperTest.java diff --git a/pom.xml b/pom.xml index dbbab0a0f..ceb9db148 100644 --- a/pom.xml +++ b/pom.xml @@ -30,6 +30,7 @@ 2.2 true 1.35 + 0.10.5 @@ -79,6 +80,25 @@ display-url-api 2.0 + + + io.jsonwebtoken + jjwt-api + ${jjwt.version} + + + io.jsonwebtoken + jjwt-impl + ${jjwt.version} + runtime + + + io.jsonwebtoken + jjwt-jackson + ${jjwt.version} + runtime + + 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 99b681fa2..9b0be9f1f 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 @@ -25,6 +25,7 @@ package org.jenkinsci.plugins.github_branch_source; import com.cloudbees.jenkins.GitHubWebHook; +import com.cloudbees.jenkins.plugins.sshcredentials.SSHUserPrivateKey; import com.cloudbees.plugins.credentials.CredentialsMatcher; import com.cloudbees.plugins.credentials.CredentialsMatchers; import com.cloudbees.plugins.credentials.CredentialsNameProvider; @@ -78,13 +79,19 @@ import org.jenkinsci.plugins.github.config.GitHubServerConfig; import org.kohsuke.accmod.Restricted; import org.kohsuke.accmod.restrictions.NoExternalUse; +import org.kohsuke.github.GHApp; +import org.kohsuke.github.GHAppInstallation; +import org.kohsuke.github.GHAppInstallationToken; import org.kohsuke.github.GitHub; import org.kohsuke.github.GitHubBuilder; import org.kohsuke.github.HttpConnector; import org.kohsuke.github.RateLimitHandler; import org.kohsuke.github.extras.OkHttpConnector; +import static com.cloudbees.plugins.credentials.CredentialsMatchers.anyOf; +import static com.cloudbees.plugins.credentials.CredentialsMatchers.instanceOf; import static java.util.logging.Level.FINE; +import static org.jenkinsci.plugins.github_branch_source.JwtHelper.createJWT; /** * Utilities that could perhaps be moved into {@code github-api}. @@ -106,6 +113,7 @@ protected boolean removeEldestEntry(Map.Entry eldest) { }; private static final Random ENTROPY = new Random(); private static final String SALT = Long.toHexString(ENTROPY.nextLong()); + private static final String ERROR_AUTHENTICATING_GITHUB_APP = "Couldn't find GitHub app installation %s"; private Connector() { throw new IllegalAccessError("Utility class"); @@ -198,13 +206,21 @@ public static FormValidation checkScanCredentials(@CheckForNull Item context, St GitHub connector = Connector.connect(apiUri, credentials); try { try { - return FormValidation.ok("User %s", connector.getMyself().getLogin()); - } catch (IOException e){ - return FormValidation.error("Invalid credentials"); + boolean githubAppAuthentication = credentials instanceof SSHUserPrivateKey; + if (githubAppAuthentication) { + int remaining = connector.getRateLimit().getRemaining(); + return FormValidation.ok("GHApp verified, remaining rate limit: %d", remaining); + } + + return FormValidation.ok("User %s", connector.isCredentialValid()); + } catch (Exception e) { + return FormValidation.error("Invalid credentials: %s", e.getMessage()); } } finally { Connector.release(connector); } + } catch (IllegalArgumentException | InvalidPrivateKeyException e) { + return FormValidation.error(e.getMessage()); } catch (IOException e) { // ignore, never thrown LOGGER.log(Level.WARNING, "Exception validating credentials {0} on {1}", new Object[]{ @@ -309,6 +325,9 @@ public static void checkApiUrlValidity(@Nonnull GitHub gitHub, @CheckForNull Sta } else if (credentials instanceof StandardUsernamePasswordCredentials) { StandardUsernamePasswordCredentials c = (StandardUsernamePasswordCredentials) credentials; hash = Util.getDigestOf(c.getPassword().getPlainText() + SALT); + } else if (credentials instanceof SSHUserPrivateKey) { + SSHUserPrivateKey c = (SSHUserPrivateKey) credentials; + hash = Util.getDigestOf(c.getPrivateKeys().get(0) + SALT); } else { // TODO OAuth support throw new IOException("Unsupported credential type: " + credentials.getClass().getName()); @@ -331,6 +350,7 @@ public static void checkApiUrlValidity(@Nonnull GitHub gitHub, @CheckForNull Sta String password; String hash; String authHash; + boolean githubApp = false; Jenkins jenkins = Jenkins.get(); if (credentials == null) { username = null; @@ -343,6 +363,14 @@ public static void checkApiUrlValidity(@Nonnull GitHub gitHub, @CheckForNull Sta password = c.getPassword().getPlainText(); hash = Util.getDigestOf(password + SALT); // want to ensure pooling by credential authHash = Util.getDigestOf(password + "::" + jenkins.getLegacyInstanceId()); + } else if (credentials instanceof SSHUserPrivateKey) { + SSHUserPrivateKey c = (SSHUserPrivateKey) credentials; + username = c.getUsername(); + password = c.getPrivateKeys().get(0); + hash = Util.getDigestOf(password + SALT); + authHash = Util.getDigestOf(password + "::" + jenkins.getLegacyInstanceId()); + + githubApp = true; } else { // TODO OAuth support throw new IOException("Unsupported credential type: " + credentials.getClass().getName()); @@ -394,8 +422,10 @@ public static void checkApiUrlValidity(@Nonnull GitHub gitHub, @CheckForNull Sta gb.withConnector(new OkHttpConnector(new OkUrlFactory(client))); - if (username != null) { + if (username != null && !githubApp) { gb.withPassword(username, password); + } else if (username != null) { + gb.withOAuthToken(generateAppInstallationToken(username, password), ""); } hub = gb.build(); @@ -406,6 +436,29 @@ public static void checkApiUrlValidity(@Nonnull GitHub gitHub, @CheckForNull Sta } } + @SuppressWarnings("deprecation") // preview features are required for GitHub app integration, GitHub api adds deprecated to all preview methods + private static String generateAppInstallationToken(String appId, String appPrivateKey) { + try { + String jwtToken = createJWT(appId, appPrivateKey); + GitHub gitHubApp = new GitHubBuilder().withJwtToken(jwtToken).build(); + + GHApp app = gitHubApp.getApp(); + + List appInstallations = app.listInstallations().asList(); + if (!appInstallations.isEmpty()) { + GHAppInstallation appInstallation = appInstallations.get(0); + GHAppInstallationToken appInstallationToken = appInstallation + .createToken(appInstallation.getPermissions()) + .create(); + + return appInstallationToken.getToken(); + } + } catch (IOException e) { + throw new IllegalArgumentException(String.format(ERROR_AUTHENTICATING_GITHUB_APP, appId), e); + } + throw new IllegalArgumentException(String.format(ERROR_AUTHENTICATING_GITHUB_APP, appId)); + } + public static void release(@CheckForNull GitHub hub) { if (hub == null) { return; @@ -453,8 +506,10 @@ private static void unused(@Nonnull GitHub hub) { } private static CredentialsMatcher githubScanCredentialsMatcher() { - // TODO OAuth credentials - return CredentialsMatchers.anyOf(CredentialsMatchers.instanceOf(StandardUsernamePasswordCredentials.class)); + return anyOf( + instanceOf(StandardUsernamePasswordCredentials.class), + instanceOf(SSHUserPrivateKey.class) + ); } static List githubDomainRequirements(String apiUri) { @@ -512,7 +567,7 @@ static boolean isCredentialValid(GitHub gitHub) { return true; } else { try { - gitHub.getMyself(); + gitHub.getRateLimit(); return true; } catch (IOException e) { if (LOGGER.isLoggable(FINE)) { diff --git a/src/main/java/org/jenkinsci/plugins/github_branch_source/GitHubSCMNavigator.java b/src/main/java/org/jenkinsci/plugins/github_branch_source/GitHubSCMNavigator.java index e4b2e1ea3..122424d13 100644 --- a/src/main/java/org/jenkinsci/plugins/github_branch_source/GitHubSCMNavigator.java +++ b/src/main/java/org/jenkinsci/plugins/github_branch_source/GitHubSCMNavigator.java @@ -25,6 +25,7 @@ package org.jenkinsci.plugins.github_branch_source; import com.cloudbees.jenkins.GitHubWebHook; +import com.cloudbees.jenkins.plugins.sshcredentials.SSHUserPrivateKey; import com.cloudbees.plugins.credentials.CredentialsNameProvider; import com.cloudbees.plugins.credentials.common.StandardCredentials; import com.cloudbees.plugins.credentials.common.StandardListBoxModel; @@ -933,7 +934,8 @@ public void visitSources(SCMSourceObserver observer) throws IOException, Interru SourceFactory sourceFactory = new SourceFactory(request); WitnessImpl witness = new WitnessImpl(listener); - if (!github.isAnonymous()) { + boolean githubAppAuthentication = credentials instanceof SSHUserPrivateKey; + if (!github.isAnonymous() && !githubAppAuthentication) { GHMyself myself; try { // Requires an authenticated access diff --git a/src/main/java/org/jenkinsci/plugins/github_branch_source/InvalidPrivateKeyException.java b/src/main/java/org/jenkinsci/plugins/github_branch_source/InvalidPrivateKeyException.java new file mode 100644 index 000000000..cc1f8d65f --- /dev/null +++ b/src/main/java/org/jenkinsci/plugins/github_branch_source/InvalidPrivateKeyException.java @@ -0,0 +1,8 @@ +package org.jenkinsci.plugins.github_branch_source; + +public class InvalidPrivateKeyException extends RuntimeException { + + public InvalidPrivateKeyException(String message) { + super(message); + } +} 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 new file mode 100644 index 000000000..6201d415a --- /dev/null +++ b/src/main/java/org/jenkinsci/plugins/github_branch_source/JwtHelper.java @@ -0,0 +1,82 @@ +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 java.util.Objects; + +import static java.util.Objects.requireNonNull; + +class JwtHelper { + + /** + * 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); + + long expMillis = nowMillis + (60 * 1000 * 10); + Date exp = new Date(expMillis); + 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", "") + .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/main/resources/org/jenkinsci/plugins/github_branch_source/GitHubSCMNavigator/help-credentialsId.html b/src/main/resources/org/jenkinsci/plugins/github_branch_source/GitHubSCMNavigator/help-credentialsId.html index a77b93fbd..322f0a622 100644 --- a/src/main/resources/org/jenkinsci/plugins/github_branch_source/GitHubSCMNavigator/help-credentialsId.html +++ b/src/main/resources/org/jenkinsci/plugins/github_branch_source/GitHubSCMNavigator/help-credentialsId.html @@ -3,11 +3,16 @@ Credentials used to scan branches and pull requests, check out sources and mark commit statuses.

-

- Note that only "username with password" credentials are supported. - Existing credentials of other kinds will be filtered out. This is because Jenkins - uses the GitHub API, which does not support other ways of authentication. -

+ The following credential types are supported: +
    +
  • + "Username with password" +
  • +
  • + "SSH Username with private key" - this is for authenticating as a GitHub app. + The username should be the 'GitHub app ID'. +
  • +

If none is given, only the public repositories will be scanned, and commit status will not be set on GitHub. diff --git a/src/main/resources/org/jenkinsci/plugins/github_branch_source/GitHubSCMSource/help-credentialsId.html b/src/main/resources/org/jenkinsci/plugins/github_branch_source/GitHubSCMSource/help-credentialsId.html index a77b93fbd..df36ebcba 100644 --- a/src/main/resources/org/jenkinsci/plugins/github_branch_source/GitHubSCMSource/help-credentialsId.html +++ b/src/main/resources/org/jenkinsci/plugins/github_branch_source/GitHubSCMSource/help-credentialsId.html @@ -3,6 +3,17 @@ Credentials used to scan branches and pull requests, check out sources and mark commit statuses.

+ The following credential types are supported: +
    +
  • + "Username with password" +
  • +
  • + "SSH Username with private key" - this is for authenticating as a GitHub app. + The username should be the 'GitHub app ID'. +
  • +
+

Note that only "username with password" credentials are supported. Existing credentials of other kinds will be filtered out. This is because Jenkins 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 new file mode 100644 index 000000000..139ec5327 --- /dev/null +++ b/src/test/java/org/jenkinsci/plugins/github_branch_source/JwtHelperTest.java @@ -0,0 +1,128 @@ +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 + private static final String PKCS8_PRIVATE_KEY = "-----BEGIN PRIVATE KEY-----\n" + + "MIIEvgIBADANBgkqhkiG9w0BAQEFAASCBKgwggSkAgEAAoIBAQD7vHsVwyDV8cj7\n" + + "5yR4WWl6rlgf/e5zmeBgtm0PCgnitcSbD5FU33301DPY5a7AtqVBOwEnE14L9XS7\n" + + "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-----"; + + private 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-----"; + + @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_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); + } +} \ No newline at end of file From 96bfe1301b32845aba36714aa85c65a62affc915 Mon Sep 17 00:00:00 2001 From: Tim Jacomb Date: Sun, 2 Feb 2020 21:10:59 +0000 Subject: [PATCH 02/18] Pass api url through to fix GHE --- .../jenkinsci/plugins/github_branch_source/Connector.java | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) 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 9b0be9f1f..553ef6c84 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 @@ -425,7 +425,7 @@ public static void checkApiUrlValidity(@Nonnull GitHub gitHub, @CheckForNull Sta if (username != null && !githubApp) { gb.withPassword(username, password); } else if (username != null) { - gb.withOAuthToken(generateAppInstallationToken(username, password), ""); + gb.withOAuthToken(generateAppInstallationToken(username, password, apiUrl), ""); } hub = gb.build(); @@ -437,10 +437,10 @@ public static void checkApiUrlValidity(@Nonnull GitHub gitHub, @CheckForNull Sta } @SuppressWarnings("deprecation") // preview features are required for GitHub app integration, GitHub api adds deprecated to all preview methods - private static String generateAppInstallationToken(String appId, String appPrivateKey) { + private static String generateAppInstallationToken(String appId, String appPrivateKey, String apiUrl) { try { String jwtToken = createJWT(appId, appPrivateKey); - GitHub gitHubApp = new GitHubBuilder().withJwtToken(jwtToken).build(); + GitHub gitHubApp = new GitHubBuilder().withEndpoint(apiUrl).withJwtToken(jwtToken).build(); GHApp app = gitHubApp.getApp(); From 5225a5d136baaa6aa41aee3bd5ad4dc914be87ae Mon Sep 17 00:00:00 2001 From: Tim Jacomb Date: Sat, 1 Feb 2020 16:12:23 +0100 Subject: [PATCH 03/18] Add documentation --- README.md | 10 ++++ docs/github-app.adoc | 111 +++++++++++++++++++++++++++++++++++++++ docs/implementation.adoc | 2 +- 3 files changed, 122 insertions(+), 1 deletion(-) create mode 100644 docs/github-app.adoc diff --git a/README.md b/README.md index c27c8ff9a..96c3259d7 100644 --- a/README.md +++ b/README.md @@ -10,6 +10,16 @@ The GitHub Branch Source plugin allows you to create a new project based on the GitHub users or organizations. Complete documentation is [hosted by CloudBees](https://docs.cloudbees.com/docs/admin-resources/latest/plugins/github-branch-source). +### Guides + +* [GitHub app authentication](docs/github-app.adoc) +* [Extension points provided by this plugin](docs/implementation.adoc) + +## Extension plugins + +* [github-scm-trait-notification-context](https://github.com/jenkinsci/github-scm-trait-notification-context-plugin) - +allows overriding the `continuous-integration/jenkins/` commit status name. + ## Version History See [the changelog](CHANGELOG.md). diff --git a/docs/github-app.adoc b/docs/github-app.adoc new file mode 100644 index 000000000..dd5cc67bd --- /dev/null +++ b/docs/github-app.adoc @@ -0,0 +1,111 @@ += GitHub app authentication guide + +This guide is targeted to users who want to use a link:https://developer.github.com/v3/apps/[GitHub app] +to authenticate to Jenkins. + +== Why? + +- the link:https://developer.github.com/apps/building-github-apps/understanding-rate-limits-for-github-apps/[rate limit] +for a GitHub app scales with your organization size, whereas a user based token has a limit of 5000 regardless of +how many repositories you have, +- for organization's that have 2fa enforced - no need to manage 2fa tokens for a 'bot' user + +== Getting started + +Before you get started make sure you have the required permissions: + +=== GitHub + +You'll need the permission to create a GitHub app, if you're creating it on a personal account then you can skip this section, +otherwise: + +- organization owner + +or + +- permission to manage GitHub apps has been +link:https://help.github.com/en/github/setting-up-and-managing-organizations-and-teams/adding-github-app-managers-in-your-organization[delegated to you]. + +=== Jenkins + +You'll need the permission to create a new credential and update job configuration, the specific permissions are: + +- Credentials/Create +- Job/Configure + +== Creating the GitHub app + +link:https://developer.github.com/apps/building-github-apps/creating-a-github-app/[Follow the GitHub guide for creating an app] + +The only fields you need to fill out (currently) are: + +- Github App name - i.e. `Jenkins - ` +- Homepage URL - your company domain or a github repository +- Webhook URL - your jenkins instance, i.e. `https:///github-webhook/` + +Permissions this plugin uses: + +- Commit statuses - read and write +- Webhooks (optional) - if you want the plugin to manage webhooks for you, read and write + + +Click 'Create GitHub app' + +You now need to generate a private key authenticating to the GitHub app + +Click the 'generate a private key' option. + +After a couple of seconds the key will be downloaded to your downloads folder. + +Now you need to convert the key into a different format that Jenkins can use: + +[source,shell] +---- +openssl pkcs8 -topk8 -inform PEM -outform PEM -in key-in-your-downloads-folder.pem -out converted-github-app.pem -nocrypt +---- + +== Adding the Jenkins credential + +- From the Jenkins main page click 'Credentials' +- Pick your credential store, normally `(global)` +- Click 'Add credentials' + +Fill out the form: + +- Kind: SSH username with private key +- ID: i.e. github-app- +- Username: the github app ID, it can be found in the 'About' section of your GitHub app in the general tab. +- Private key: enter directly, paste the contents of the converted private key +- Passphrase: do not fill this field, it will be ignored +- Click OK + +== Configuring the github organization folder + +See the link:https://docs.cloudbees.com/docs/admin-resources/latest/plugins/github-branch-source[main documentation] +for how to create a GitHub folder. + +- Load the folders configuration page +- Select the GitHub app credentials in the 'Credentials field drop down + +After selecting the credential you should see: + +[quote] +---- +GHApp verified, remaining rate limit: 5000 +---- + +- Click save +- Click 'Scan organization now' +- Click 'Scan organisation log' + +Verify at the bottom of the scan log it says: + +[quote] +---- +Finished: SUCCESS +---- + +=== Help? + +Raise an issue on link:https://issues.jenkins-ci.org/[Jenkins jira] +setting the 'component' to be `github-brance-source-plugin` diff --git a/docs/implementation.adoc b/docs/implementation.adoc index b60d9b871..f1d493080 100644 --- a/docs/implementation.adoc +++ b/docs/implementation.adoc @@ -45,6 +45,6 @@ explicitly apply a `DefaultGitHubNotificationStrategy` to the source context in Duplicate (by equality) strategies are ignored when applied to the source context. ==== Implementations: -https://github.com/steven-foster/github-scm-trait-notification-context[github-scm-trait-notification-context] +https://github.com/jenkinsci/github-scm-trait-notification-context-plugin[github-scm-trait-notification-context] From 42e6826d039fb0b7e2846447b3370a63392b1008 Mon Sep 17 00:00:00 2001 From: Tim Jacomb Date: Sun, 2 Feb 2020 20:22:30 +0100 Subject: [PATCH 04/18] More logging on failure --- .../org/jenkinsci/plugins/github_branch_source/Connector.java | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) 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 553ef6c84..6073346e1 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 @@ -220,7 +220,9 @@ public static FormValidation checkScanCredentials(@CheckForNull Item context, St Connector.release(connector); } } catch (IllegalArgumentException | InvalidPrivateKeyException e) { - return FormValidation.error(e.getMessage()); + String msg = "Exception validating credentials " + CredentialsNameProvider.name(credentials); + LOGGER.log(Level.WARNING, msg, e); + return FormValidation.error(e, msg); } catch (IOException e) { // ignore, never thrown LOGGER.log(Level.WARNING, "Exception validating credentials {0} on {1}", new Object[]{ From 8010d4d27cd486c6cd5d23df61a4e760c6302582 Mon Sep 17 00:00:00 2001 From: Praveen Adusumilli <47391951+adusumillipraveen@users.noreply.github.com> Date: Mon, 3 Feb 2020 15:37:57 +0000 Subject: [PATCH 05/18] Update github-app.adoc --- docs/github-app.adoc | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/docs/github-app.adoc b/docs/github-app.adoc index dd5cc67bd..3747736ce 100644 --- a/docs/github-app.adoc +++ b/docs/github-app.adoc @@ -45,8 +45,11 @@ The only fields you need to fill out (currently) are: Permissions this plugin uses: -- Commit statuses - read and write -- Webhooks (optional) - if you want the plugin to manage webhooks for you, read and write +- Commit statuses - Read and Write +- Contents: Read-only (to read the Jenkinsfile) +- Metadata: Read-only +- Pull requests: Read-only +- Webhooks (optional) - If you want the plugin to manage webhooks for you, Read and Write Click 'Create GitHub app' From 94157456c917e1a20e6250f3385bb85065c5e91f Mon Sep 17 00:00:00 2001 From: Praveen Adusumilli <47391951+adusumillipraveen@users.noreply.github.com> Date: Tue, 4 Feb 2020 15:29:00 +0000 Subject: [PATCH 06/18] Update github-app.adoc --- docs/github-app.adoc | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/docs/github-app.adoc b/docs/github-app.adoc index 3747736ce..ec34eae1b 100644 --- a/docs/github-app.adoc +++ b/docs/github-app.adoc @@ -67,6 +67,10 @@ Now you need to convert the key into a different format that Jenkins can use: openssl pkcs8 -topk8 -inform PEM -outform PEM -in key-in-your-downloads-folder.pem -out converted-github-app.pem -nocrypt ---- +== Install the GitHub app + +- From the install app section of newly created app, install the app to your organization. + == Adding the Jenkins credential - From the Jenkins main page click 'Credentials' From df39f0c1833399d06b6f8cb4ea884ba5bd229c1e Mon Sep 17 00:00:00 2001 From: Tim Jacomb Date: Tue, 4 Feb 2020 20:29:14 +0000 Subject: [PATCH 07/18] Update docs/github-app.adoc Co-Authored-By: Olivier Jacques --- docs/github-app.adoc | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/github-app.adoc b/docs/github-app.adoc index ec34eae1b..d2cec6902 100644 --- a/docs/github-app.adoc +++ b/docs/github-app.adoc @@ -9,6 +9,7 @@ to authenticate to Jenkins. for a GitHub app scales with your organization size, whereas a user based token has a limit of 5000 regardless of how many repositories you have, - for organization's that have 2fa enforced - no need to manage 2fa tokens for a 'bot' user +- to improve and tighten security: the Jenkins GitHub app requires a minimum, controlled set of privileges compared to a service user and its personal access token which has a much wider set of privileges == Getting started From 262f07e8d134f9d17905f428b7e4aba812f88859 Mon Sep 17 00:00:00 2001 From: Tim Jacomb Date: Tue, 4 Feb 2020 21:55:38 +0000 Subject: [PATCH 08/18] Update src/main/java/org/jenkinsci/plugins/github_branch_source/Connector.java Co-Authored-By: Olivier Jacques --- .../org/jenkinsci/plugins/github_branch_source/Connector.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 6073346e1..69f1c6b06 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 @@ -113,7 +113,7 @@ protected boolean removeEldestEntry(Map.Entry eldest) { }; private static final Random ENTROPY = new Random(); private static final String SALT = Long.toHexString(ENTROPY.nextLong()); - private static final String ERROR_AUTHENTICATING_GITHUB_APP = "Couldn't find GitHub app installation %s"; + private static final String ERROR_AUTHENTICATING_GITHUB_APP = "Couldn't authenticate with GitHub app ID %s"; private Connector() { throw new IllegalAccessError("Utility class"); From cd4b7952844c2b7cf77dbcf33d1a4fed400db7ee Mon Sep 17 00:00:00 2001 From: Tim Jacomb Date: Wed, 5 Feb 2020 19:17:44 +0000 Subject: [PATCH 09/18] Create GitHubAppCredential --- pom.xml | 8 +- .../github_branch_source/Connector.java | 49 +----- .../GitHubAppCredential.java | 144 ++++++++++++++++++ .../GitHubSCMNavigator.java | 2 +- .../SSHCheckoutTrait.java | 19 ++- .../GitHubAppCredential/config.jelly | 14 ++ .../github_branch_source/Messages.properties | 2 + 7 files changed, 188 insertions(+), 50 deletions(-) create mode 100644 src/main/java/org/jenkinsci/plugins/github_branch_source/GitHubAppCredential.java create mode 100644 src/main/resources/org/jenkinsci/plugins/github_branch_source/GitHubAppCredential/config.jelly diff --git a/pom.xml b/pom.xml index ceb9db148..058a38260 100644 --- a/pom.xml +++ b/pom.xml @@ -68,7 +68,13 @@ org.jenkins-ci.plugins credentials - 2.1.18 + 2.2.0 + + + + io.jenkins.temp.jelly + multiline-secrets-ui + 1.0 com.coravy.hudson.plugins.github 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 69f1c6b06..4974a3eb0 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 @@ -77,21 +77,14 @@ import org.apache.commons.lang.StringUtils; import org.jenkinsci.plugins.gitclient.GitClient; import org.jenkinsci.plugins.github.config.GitHubServerConfig; -import org.kohsuke.accmod.Restricted; -import org.kohsuke.accmod.restrictions.NoExternalUse; -import org.kohsuke.github.GHApp; -import org.kohsuke.github.GHAppInstallation; -import org.kohsuke.github.GHAppInstallationToken; import org.kohsuke.github.GitHub; import org.kohsuke.github.GitHubBuilder; -import org.kohsuke.github.HttpConnector; import org.kohsuke.github.RateLimitHandler; import org.kohsuke.github.extras.OkHttpConnector; import static com.cloudbees.plugins.credentials.CredentialsMatchers.anyOf; import static com.cloudbees.plugins.credentials.CredentialsMatchers.instanceOf; import static java.util.logging.Level.FINE; -import static org.jenkinsci.plugins.github_branch_source.JwtHelper.createJWT; /** * Utilities that could perhaps be moved into {@code github-api}. @@ -113,7 +106,6 @@ protected boolean removeEldestEntry(Map.Entry eldest) { }; private static final Random ENTROPY = new Random(); private static final String SALT = Long.toHexString(ENTROPY.nextLong()); - private static final String ERROR_AUTHENTICATING_GITHUB_APP = "Couldn't authenticate with GitHub app ID %s"; private Connector() { throw new IllegalAccessError("Utility class"); @@ -206,7 +198,7 @@ public static FormValidation checkScanCredentials(@CheckForNull Item context, St GitHub connector = Connector.connect(apiUri, credentials); try { try { - boolean githubAppAuthentication = credentials instanceof SSHUserPrivateKey; + boolean githubAppAuthentication = credentials instanceof GitHubAppCredential; if (githubAppAuthentication) { int remaining = connector.getRateLimit().getRemaining(); return FormValidation.ok("GHApp verified, remaining rate limit: %d", remaining); @@ -327,9 +319,6 @@ public static void checkApiUrlValidity(@Nonnull GitHub gitHub, @CheckForNull Sta } else if (credentials instanceof StandardUsernamePasswordCredentials) { StandardUsernamePasswordCredentials c = (StandardUsernamePasswordCredentials) credentials; hash = Util.getDigestOf(c.getPassword().getPlainText() + SALT); - } else if (credentials instanceof SSHUserPrivateKey) { - SSHUserPrivateKey c = (SSHUserPrivateKey) credentials; - hash = Util.getDigestOf(c.getPrivateKeys().get(0) + SALT); } else { // TODO OAuth support throw new IOException("Unsupported credential type: " + credentials.getClass().getName()); @@ -352,7 +341,6 @@ public static void checkApiUrlValidity(@Nonnull GitHub gitHub, @CheckForNull Sta String password; String hash; String authHash; - boolean githubApp = false; Jenkins jenkins = Jenkins.get(); if (credentials == null) { username = null; @@ -365,14 +353,6 @@ public static void checkApiUrlValidity(@Nonnull GitHub gitHub, @CheckForNull Sta password = c.getPassword().getPlainText(); hash = Util.getDigestOf(password + SALT); // want to ensure pooling by credential authHash = Util.getDigestOf(password + "::" + jenkins.getLegacyInstanceId()); - } else if (credentials instanceof SSHUserPrivateKey) { - SSHUserPrivateKey c = (SSHUserPrivateKey) credentials; - username = c.getUsername(); - password = c.getPrivateKeys().get(0); - hash = Util.getDigestOf(password + SALT); - authHash = Util.getDigestOf(password + "::" + jenkins.getLegacyInstanceId()); - - githubApp = true; } else { // TODO OAuth support throw new IOException("Unsupported credential type: " + credentials.getClass().getName()); @@ -424,10 +404,8 @@ public static void checkApiUrlValidity(@Nonnull GitHub gitHub, @CheckForNull Sta gb.withConnector(new OkHttpConnector(new OkUrlFactory(client))); - if (username != null && !githubApp) { + if (username != null) { gb.withPassword(username, password); - } else if (username != null) { - gb.withOAuthToken(generateAppInstallationToken(username, password, apiUrl), ""); } hub = gb.build(); @@ -438,29 +416,6 @@ public static void checkApiUrlValidity(@Nonnull GitHub gitHub, @CheckForNull Sta } } - @SuppressWarnings("deprecation") // preview features are required for GitHub app integration, GitHub api adds deprecated to all preview methods - private static String generateAppInstallationToken(String appId, String appPrivateKey, String apiUrl) { - try { - String jwtToken = createJWT(appId, appPrivateKey); - GitHub gitHubApp = new GitHubBuilder().withEndpoint(apiUrl).withJwtToken(jwtToken).build(); - - GHApp app = gitHubApp.getApp(); - - List appInstallations = app.listInstallations().asList(); - if (!appInstallations.isEmpty()) { - GHAppInstallation appInstallation = appInstallations.get(0); - GHAppInstallationToken appInstallationToken = appInstallation - .createToken(appInstallation.getPermissions()) - .create(); - - return appInstallationToken.getToken(); - } - } catch (IOException e) { - throw new IllegalArgumentException(String.format(ERROR_AUTHENTICATING_GITHUB_APP, appId), e); - } - throw new IllegalArgumentException(String.format(ERROR_AUTHENTICATING_GITHUB_APP, appId)); - } - public static void release(@CheckForNull GitHub hub) { if (hub == null) { return; diff --git a/src/main/java/org/jenkinsci/plugins/github_branch_source/GitHubAppCredential.java b/src/main/java/org/jenkinsci/plugins/github_branch_source/GitHubAppCredential.java new file mode 100644 index 000000000..af9383c50 --- /dev/null +++ b/src/main/java/org/jenkinsci/plugins/github_branch_source/GitHubAppCredential.java @@ -0,0 +1,144 @@ +package org.jenkinsci.plugins.github_branch_source; + +import com.cloudbees.plugins.credentials.CredentialsScope; +import com.cloudbees.plugins.credentials.common.StandardUsernamePasswordCredentials; +import com.cloudbees.plugins.credentials.impl.BaseStandardCredentials; +import edu.umd.cs.findbugs.annotations.CheckForNull; +import edu.umd.cs.findbugs.annotations.NonNull; +import hudson.Extension; +import hudson.Util; +import hudson.util.Secret; +import java.io.IOException; +import java.util.List; +import org.kohsuke.github.GHApp; +import org.kohsuke.github.GHAppInstallation; +import org.kohsuke.github.GHAppInstallationToken; +import org.kohsuke.github.GitHub; +import org.kohsuke.github.GitHubBuilder; +import org.kohsuke.stapler.DataBoundConstructor; + +import static org.jenkinsci.plugins.github_branch_source.JwtHelper.createJWT; + +public class GitHubAppCredential extends BaseStandardCredentials implements StandardUsernamePasswordCredentials { + + private static final String ERROR_AUTHENTICATING_GITHUB_APP = "Couldn't authenticate with GitHub app ID %s"; + + @NonNull + private final String appID; + + @NonNull + private final Secret privateKey; + + private String apiUrl; + + /** + * Constructor. + * + * @param scope the credentials scope + * @param id the ID or {@code null} to generate a new one. + * @param description the description. + * @param appID the username. + * @param privateKey the password. + */ + @DataBoundConstructor + @SuppressWarnings("unused") // by stapler + public GitHubAppCredential( + CredentialsScope scope, + String id, + @CheckForNull String description, + @NonNull String appID, + @NonNull Secret privateKey + ) { + super(scope, id, description); + this.appID = appID; + this.privateKey = privateKey; + } + + public String getApiUrl() { + return apiUrl; + } + + public void setApiUrl(String apiUrl) { + this.apiUrl = apiUrl; + } + + @NonNull + public String getAppID() { + return appID; + } + + @NonNull + public Secret getPrivateKey() { + return privateKey; + } + + @SuppressWarnings("deprecation") // preview features are required for GitHub app integration, GitHub api adds deprecated to all preview methods + static String generateAppInstallationToken(String appId, String appPrivateKey, String apiUrl) { + try { + String jwtToken = createJWT(appId, appPrivateKey); + GitHub gitHubApp = new GitHubBuilder().withEndpoint(apiUrl).withJwtToken(jwtToken).build(); + + GHApp app = gitHubApp.getApp(); + + List appInstallations = app.listInstallations().asList(); + if (!appInstallations.isEmpty()) { + GHAppInstallation appInstallation = appInstallations.get(0); + GHAppInstallationToken appInstallationToken = appInstallation + .createToken(appInstallation.getPermissions()) + .create(); + + return appInstallationToken.getToken(); + } + } catch (IOException e) { + throw new IllegalArgumentException(String.format(ERROR_AUTHENTICATING_GITHUB_APP, appId), e); + } + throw new IllegalArgumentException(String.format(ERROR_AUTHENTICATING_GITHUB_APP, appId)); + } + + /** + * {@inheritDoc} + */ + @NonNull + @Override + public Secret getPassword() { + if (Util.fixEmpty(apiUrl) == null) { + apiUrl = "https://api.github.com"; + } + + String appInstallationToken = generateAppInstallationToken(appID, privateKey.getPlainText(), apiUrl); + + return Secret.fromString(appInstallationToken); + } + + /** + * {@inheritDoc} + */ + @NonNull + @Override + public String getUsername() { + return appID; + } + + /** + * {@inheritDoc} + */ + @Extension + public static class DescriptorImpl extends BaseStandardCredentialsDescriptor { + + /** + * {@inheritDoc} + */ + @Override + public String getDisplayName() { + return Messages.GitHubAppCredential_displayName(); + } + + /** + * {@inheritDoc} + */ + @Override + public String getIconClassName() { + return "icon-github-logo"; + } + } +} diff --git a/src/main/java/org/jenkinsci/plugins/github_branch_source/GitHubSCMNavigator.java b/src/main/java/org/jenkinsci/plugins/github_branch_source/GitHubSCMNavigator.java index 122424d13..eed2b8195 100644 --- a/src/main/java/org/jenkinsci/plugins/github_branch_source/GitHubSCMNavigator.java +++ b/src/main/java/org/jenkinsci/plugins/github_branch_source/GitHubSCMNavigator.java @@ -934,7 +934,7 @@ public void visitSources(SCMSourceObserver observer) throws IOException, Interru SourceFactory sourceFactory = new SourceFactory(request); WitnessImpl witness = new WitnessImpl(listener); - boolean githubAppAuthentication = credentials instanceof SSHUserPrivateKey; + boolean githubAppAuthentication = credentials instanceof GitHubAppCredential; if (!github.isAnonymous() && !githubAppAuthentication) { GHMyself myself; try { diff --git a/src/main/java/org/jenkinsci/plugins/github_branch_source/SSHCheckoutTrait.java b/src/main/java/org/jenkinsci/plugins/github_branch_source/SSHCheckoutTrait.java index aa583aa34..14cdc16e9 100644 --- a/src/main/java/org/jenkinsci/plugins/github_branch_source/SSHCheckoutTrait.java +++ b/src/main/java/org/jenkinsci/plugins/github_branch_source/SSHCheckoutTrait.java @@ -35,12 +35,12 @@ import hudson.Util; import hudson.model.Item; import hudson.model.Queue; -import hudson.model.queue.Tasks; import hudson.plugins.git.GitSCM; import hudson.scm.SCM; import hudson.security.ACL; import hudson.util.FormValidation; import hudson.util.ListBoxModel; +import java.util.Objects; import jenkins.model.Jenkins; import jenkins.plugins.git.GitSCMBuilder; import jenkins.scm.api.SCMSource; @@ -105,6 +105,23 @@ protected void decorateBuilder(SCMBuilder builder) { ((GitHubSCMBuilder)builder).withCredentials(credentialsId, GitHubSCMBuilder.SSH); } + @Override + public boolean equals(Object o) { + if (this == o) { + return true; + } + if (o == null || getClass() != o.getClass()) { + return false; + } + SSHCheckoutTrait that = (SSHCheckoutTrait) o; + return Objects.equals(credentialsId, that.credentialsId); + } + + @Override + public int hashCode() { + return Objects.hash(credentialsId); + } + /** * Our descriptor. */ diff --git a/src/main/resources/org/jenkinsci/plugins/github_branch_source/GitHubAppCredential/config.jelly b/src/main/resources/org/jenkinsci/plugins/github_branch_source/GitHubAppCredential/config.jelly new file mode 100644 index 000000000..953a7ec07 --- /dev/null +++ b/src/main/resources/org/jenkinsci/plugins/github_branch_source/GitHubAppCredential/config.jelly @@ -0,0 +1,14 @@ + + + + + + + + + + + + \ No newline at end of file diff --git a/src/main/resources/org/jenkinsci/plugins/github_branch_source/Messages.properties b/src/main/resources/org/jenkinsci/plugins/github_branch_source/Messages.properties index 72b7e2bac..b609ef7f7 100644 --- a/src/main/resources/org/jenkinsci/plugins/github_branch_source/Messages.properties +++ b/src/main/resources/org/jenkinsci/plugins/github_branch_source/Messages.properties @@ -66,3 +66,5 @@ ExcludeArchivedRepositoriesTrait.displayName=Exclude archived repositories GitHubSCMNavigator.general=General GitHubSCMNavigator.withinRepository=Within repository + +GitHubAppCredential.displayName=GitHub app \ No newline at end of file From 9e7de1c20d1aeacbc3666a17c12846e6534ef6b7 Mon Sep 17 00:00:00 2001 From: Tim Jacomb Date: Wed, 5 Feb 2020 19:32:22 +0000 Subject: [PATCH 10/18] Revert unneeded changes --- docs/github-app.adoc | 6 +++--- .../github_branch_source/Connector.java | 7 ++----- .../GitHubAppCredential.java | 9 --------- .../SSHCheckoutTrait.java | 19 +------------------ .../help-credentialsId.html | 15 +++++---------- .../GitHubSCMSource/help-credentialsId.html | 11 ----------- 6 files changed, 11 insertions(+), 56 deletions(-) diff --git a/docs/github-app.adoc b/docs/github-app.adoc index d2cec6902..e62480213 100644 --- a/docs/github-app.adoc +++ b/docs/github-app.adoc @@ -80,10 +80,10 @@ openssl pkcs8 -topk8 -inform PEM -outform PEM -in key-in-your-downloads-folder.p Fill out the form: -- Kind: SSH username with private key +- Kind: GitHub app - ID: i.e. github-app- -- Username: the github app ID, it can be found in the 'About' section of your GitHub app in the general tab. -- Private key: enter directly, paste the contents of the converted private key +- App ID: the github app ID, it can be found in the 'About' section of your GitHub app in the general tab. +- Key: click add, paste the contents of the converted private key - Passphrase: do not fill this field, it will be ignored - Click OK 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 4974a3eb0..645b4e06b 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 @@ -25,7 +25,6 @@ package org.jenkinsci.plugins.github_branch_source; import com.cloudbees.jenkins.GitHubWebHook; -import com.cloudbees.jenkins.plugins.sshcredentials.SSHUserPrivateKey; import com.cloudbees.plugins.credentials.CredentialsMatcher; import com.cloudbees.plugins.credentials.CredentialsMatchers; import com.cloudbees.plugins.credentials.CredentialsNameProvider; @@ -463,10 +462,8 @@ private static void unused(@Nonnull GitHub hub) { } private static CredentialsMatcher githubScanCredentialsMatcher() { - return anyOf( - instanceOf(StandardUsernamePasswordCredentials.class), - instanceOf(SSHUserPrivateKey.class) - ); + // TODO OAuth credentials + return CredentialsMatchers.anyOf(CredentialsMatchers.instanceOf(StandardUsernamePasswordCredentials.class)); } static List githubDomainRequirements(String apiUri) { diff --git a/src/main/java/org/jenkinsci/plugins/github_branch_source/GitHubAppCredential.java b/src/main/java/org/jenkinsci/plugins/github_branch_source/GitHubAppCredential.java index af9383c50..f2e624ec8 100644 --- a/src/main/java/org/jenkinsci/plugins/github_branch_source/GitHubAppCredential.java +++ b/src/main/java/org/jenkinsci/plugins/github_branch_source/GitHubAppCredential.java @@ -31,15 +31,6 @@ public class GitHubAppCredential extends BaseStandardCredentials implements Stan private String apiUrl; - /** - * Constructor. - * - * @param scope the credentials scope - * @param id the ID or {@code null} to generate a new one. - * @param description the description. - * @param appID the username. - * @param privateKey the password. - */ @DataBoundConstructor @SuppressWarnings("unused") // by stapler public GitHubAppCredential( diff --git a/src/main/java/org/jenkinsci/plugins/github_branch_source/SSHCheckoutTrait.java b/src/main/java/org/jenkinsci/plugins/github_branch_source/SSHCheckoutTrait.java index 14cdc16e9..aa583aa34 100644 --- a/src/main/java/org/jenkinsci/plugins/github_branch_source/SSHCheckoutTrait.java +++ b/src/main/java/org/jenkinsci/plugins/github_branch_source/SSHCheckoutTrait.java @@ -35,12 +35,12 @@ import hudson.Util; import hudson.model.Item; import hudson.model.Queue; +import hudson.model.queue.Tasks; import hudson.plugins.git.GitSCM; import hudson.scm.SCM; import hudson.security.ACL; import hudson.util.FormValidation; import hudson.util.ListBoxModel; -import java.util.Objects; import jenkins.model.Jenkins; import jenkins.plugins.git.GitSCMBuilder; import jenkins.scm.api.SCMSource; @@ -105,23 +105,6 @@ protected void decorateBuilder(SCMBuilder builder) { ((GitHubSCMBuilder)builder).withCredentials(credentialsId, GitHubSCMBuilder.SSH); } - @Override - public boolean equals(Object o) { - if (this == o) { - return true; - } - if (o == null || getClass() != o.getClass()) { - return false; - } - SSHCheckoutTrait that = (SSHCheckoutTrait) o; - return Objects.equals(credentialsId, that.credentialsId); - } - - @Override - public int hashCode() { - return Objects.hash(credentialsId); - } - /** * Our descriptor. */ diff --git a/src/main/resources/org/jenkinsci/plugins/github_branch_source/GitHubSCMNavigator/help-credentialsId.html b/src/main/resources/org/jenkinsci/plugins/github_branch_source/GitHubSCMNavigator/help-credentialsId.html index 322f0a622..a77b93fbd 100644 --- a/src/main/resources/org/jenkinsci/plugins/github_branch_source/GitHubSCMNavigator/help-credentialsId.html +++ b/src/main/resources/org/jenkinsci/plugins/github_branch_source/GitHubSCMNavigator/help-credentialsId.html @@ -3,16 +3,11 @@ Credentials used to scan branches and pull requests, check out sources and mark commit statuses.

- The following credential types are supported: -
    -
  • - "Username with password" -
  • -
  • - "SSH Username with private key" - this is for authenticating as a GitHub app. - The username should be the 'GitHub app ID'. -
  • -
+

+ Note that only "username with password" credentials are supported. + Existing credentials of other kinds will be filtered out. This is because Jenkins + uses the GitHub API, which does not support other ways of authentication. +

If none is given, only the public repositories will be scanned, and commit status will not be set on GitHub. diff --git a/src/main/resources/org/jenkinsci/plugins/github_branch_source/GitHubSCMSource/help-credentialsId.html b/src/main/resources/org/jenkinsci/plugins/github_branch_source/GitHubSCMSource/help-credentialsId.html index df36ebcba..a77b93fbd 100644 --- a/src/main/resources/org/jenkinsci/plugins/github_branch_source/GitHubSCMSource/help-credentialsId.html +++ b/src/main/resources/org/jenkinsci/plugins/github_branch_source/GitHubSCMSource/help-credentialsId.html @@ -3,17 +3,6 @@ Credentials used to scan branches and pull requests, check out sources and mark commit statuses.

- The following credential types are supported: -
    -
  • - "Username with password" -
  • -
  • - "SSH Username with private key" - this is for authenticating as a GitHub app. - The username should be the 'GitHub app ID'. -
  • -
-

Note that only "username with password" credentials are supported. Existing credentials of other kinds will be filtered out. This is because Jenkins From e2c9374023cb236808a225c9d904758a43489c3d Mon Sep 17 00:00:00 2001 From: Tim Jacomb Date: Wed, 5 Feb 2020 20:15:28 +0000 Subject: [PATCH 11/18] Add GHE support --- .../GitHubAppCredential.java | 65 ++++++++++++++++--- .../GitHubSCMNavigator.java | 4 ++ .../GitHubAppCredential/config.jelly | 16 +++++ .../GitHubAppCredential/help-apiUri.html | 3 + 4 files changed, 80 insertions(+), 8 deletions(-) create mode 100644 src/main/resources/org/jenkinsci/plugins/github_branch_source/GitHubAppCredential/help-apiUri.html diff --git a/src/main/java/org/jenkinsci/plugins/github_branch_source/GitHubAppCredential.java b/src/main/java/org/jenkinsci/plugins/github_branch_source/GitHubAppCredential.java index f2e624ec8..107fbb360 100644 --- a/src/main/java/org/jenkinsci/plugins/github_branch_source/GitHubAppCredential.java +++ b/src/main/java/org/jenkinsci/plugins/github_branch_source/GitHubAppCredential.java @@ -7,16 +7,24 @@ import edu.umd.cs.findbugs.annotations.NonNull; import hudson.Extension; import hudson.Util; +import hudson.util.FormValidation; +import hudson.util.ListBoxModel; import hudson.util.Secret; import java.io.IOException; import java.util.List; +import org.kohsuke.accmod.Restricted; +import org.kohsuke.accmod.restrictions.NoExternalUse; import org.kohsuke.github.GHApp; import org.kohsuke.github.GHAppInstallation; import org.kohsuke.github.GHAppInstallationToken; import org.kohsuke.github.GitHub; import org.kohsuke.github.GitHubBuilder; 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; public class GitHubAppCredential extends BaseStandardCredentials implements StandardUsernamePasswordCredentials { @@ -29,7 +37,7 @@ public class GitHubAppCredential extends BaseStandardCredentials implements Stan @NonNull private final Secret privateKey; - private String apiUrl; + private String apiUri; @DataBoundConstructor @SuppressWarnings("unused") // by stapler @@ -45,12 +53,13 @@ public GitHubAppCredential( this.privateKey = privateKey; } - public String getApiUrl() { - return apiUrl; + public String getApiUri() { + return apiUri; } - public void setApiUrl(String apiUrl) { - this.apiUrl = apiUrl; + @DataBoundSetter + public void setApiUri(String apiUri) { + this.apiUri = apiUri; } @NonNull @@ -92,11 +101,11 @@ static String generateAppInstallationToken(String appId, String appPrivateKey, S @NonNull @Override public Secret getPassword() { - if (Util.fixEmpty(apiUrl) == null) { - apiUrl = "https://api.github.com"; + if (Util.fixEmpty(apiUri) == null) { + apiUri = "https://api.github.com"; } - String appInstallationToken = generateAppInstallationToken(appID, privateKey.getPlainText(), apiUrl); + String appInstallationToken = generateAppInstallationToken(appID, privateKey.getPlainText(), apiUri); return Secret.fromString(appInstallationToken); } @@ -131,5 +140,45 @@ public String getDisplayName() { public String getIconClassName() { return "icon-github-logo"; } + + @SuppressWarnings("unused") // jelly + public boolean isApiUriSelectable() { + return !GitHubConfiguration.get().getEndpoints().isEmpty(); + } + + /** + * Returns the available GitHub endpoint items. + * + * @return the available GitHub endpoint items. + */ + @SuppressWarnings("unused") // stapler + @Restricted(NoExternalUse.class) // stapler + public ListBoxModel doFillApiUriItems() { + return getPossibleApiUriItems(); + } + + @POST + @SuppressWarnings("unused") // stapler + @Restricted(NoExternalUse.class) // stapler + public FormValidation doTestConnection( + @QueryParameter("appID") final String appID, + @QueryParameter("privateKey") final String privateKey, + @QueryParameter("apiUri") final String apiUri + + ) { + GitHubAppCredential gitHubAppCredential = new GitHubAppCredential( + CredentialsScope.GLOBAL, "test-id-not-being-saved", null, + appID, Secret.fromString(privateKey) + ); + gitHubAppCredential.setApiUri(apiUri); + + try { + GitHub connect = Connector.connect(apiUri, gitHubAppCredential); + + return FormValidation.ok("Success, Remaining rate limit: " + connect.getRateLimit().getRemaining()); + } catch (Exception e) { + return FormValidation.error(e, String.format(ERROR_AUTHENTICATING_GITHUB_APP, appID)); + } + } } } diff --git a/src/main/java/org/jenkinsci/plugins/github_branch_source/GitHubSCMNavigator.java b/src/main/java/org/jenkinsci/plugins/github_branch_source/GitHubSCMNavigator.java index eed2b8195..5d58b8cea 100644 --- a/src/main/java/org/jenkinsci/plugins/github_branch_source/GitHubSCMNavigator.java +++ b/src/main/java/org/jenkinsci/plugins/github_branch_source/GitHubSCMNavigator.java @@ -1416,6 +1416,10 @@ public ListBoxModel doFillCredentialsIdItems(@CheckForNull @AncestorInPath Item @Restricted(NoExternalUse.class) // stapler @SuppressWarnings("unused") // stapler public ListBoxModel doFillApiUriItems() { + return getPossibleApiUriItems(); + } + + static ListBoxModel getPossibleApiUriItems() { ListBoxModel result = new ListBoxModel(); result.add("GitHub", ""); for (Endpoint e : GitHubConfiguration.get().getEndpoints()) { diff --git a/src/main/resources/org/jenkinsci/plugins/github_branch_source/GitHubAppCredential/config.jelly b/src/main/resources/org/jenkinsci/plugins/github_branch_source/GitHubAppCredential/config.jelly index 953a7ec07..7544d9d58 100644 --- a/src/main/resources/org/jenkinsci/plugins/github_branch_source/GitHubAppCredential/config.jelly +++ b/src/main/resources/org/jenkinsci/plugins/github_branch_source/GitHubAppCredential/config.jelly @@ -4,6 +4,18 @@ + + + + + + + + + + + + + + \ No newline at end of file diff --git a/src/main/resources/org/jenkinsci/plugins/github_branch_source/GitHubAppCredential/help-apiUri.html b/src/main/resources/org/jenkinsci/plugins/github_branch_source/GitHubAppCredential/help-apiUri.html new file mode 100644 index 000000000..72fce8569 --- /dev/null +++ b/src/main/resources/org/jenkinsci/plugins/github_branch_source/GitHubAppCredential/help-apiUri.html @@ -0,0 +1,3 @@ +

+ GitHub API endpoint such as https://github.example.com/api/v3/. +

\ No newline at end of file From c311b36f15001b461b56fbfca855042c99cf736c Mon Sep 17 00:00:00 2001 From: Tim Jacomb Date: Wed, 5 Feb 2020 20:17:02 +0000 Subject: [PATCH 12/18] Revert change back for non gh app --- .../org/jenkinsci/plugins/github_branch_source/Connector.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 645b4e06b..cf501e5e0 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 @@ -203,7 +203,7 @@ public static FormValidation checkScanCredentials(@CheckForNull Item context, St return FormValidation.ok("GHApp verified, remaining rate limit: %d", remaining); } - return FormValidation.ok("User %s", connector.isCredentialValid()); + return FormValidation.ok("User %s", connector.getMyself().getLogin()); } catch (Exception e) { return FormValidation.error("Invalid credentials: %s", e.getMessage()); } From ad2e0ca954e7b18bc9a99f11a31c207c4f3a6553 Mon Sep 17 00:00:00 2001 From: Tim Jacomb Date: Wed, 5 Feb 2020 21:34:43 +0000 Subject: [PATCH 13/18] Add more help text --- .../GitHubAppCredential/help-appID.html | 3 +++ .../GitHubAppCredential/help-privateKey.html | 6 ++++++ 2 files changed, 9 insertions(+) create mode 100644 src/main/resources/org/jenkinsci/plugins/github_branch_source/GitHubAppCredential/help-appID.html create mode 100644 src/main/resources/org/jenkinsci/plugins/github_branch_source/GitHubAppCredential/help-privateKey.html diff --git a/src/main/resources/org/jenkinsci/plugins/github_branch_source/GitHubAppCredential/help-appID.html b/src/main/resources/org/jenkinsci/plugins/github_branch_source/GitHubAppCredential/help-appID.html new file mode 100644 index 000000000..d85fa8314 --- /dev/null +++ b/src/main/resources/org/jenkinsci/plugins/github_branch_source/GitHubAppCredential/help-appID.html @@ -0,0 +1,3 @@ +

+ GitHub app ID, can be found on the App's settings, on the General page in the About section +

\ No newline at end of file diff --git a/src/main/resources/org/jenkinsci/plugins/github_branch_source/GitHubAppCredential/help-privateKey.html b/src/main/resources/org/jenkinsci/plugins/github_branch_source/GitHubAppCredential/help-privateKey.html new file mode 100644 index 000000000..00374b9d8 --- /dev/null +++ b/src/main/resources/org/jenkinsci/plugins/github_branch_source/GitHubAppCredential/help-privateKey.html @@ -0,0 +1,6 @@ +

+ Private key for authenticating to GitHub with, it must be in PKCS#8 format, GitHub will give it to you in PKCS#1. +

+

+ You can convert it with openssl pkcs8 -topk8 -inform PEM -outform PEM -in current-key.pem -out new-key.pem -nocrypt +

\ No newline at end of file From 07dc6a6a74b033ab4a01fc64f6ff4fb72501b0f1 Mon Sep 17 00:00:00 2001 From: Tim Jacomb Date: Thu, 6 Feb 2020 07:40:41 +0000 Subject: [PATCH 14/18] Rename credential class So that JCasC symbol name is resolved to: githubApp and not githubAppCredential --- .../jenkinsci/plugins/github_branch_source/Connector.java | 4 +--- ...GitHubAppCredential.java => GitHubAppCredentials.java} | 8 ++++---- .../plugins/github_branch_source/GitHubSCMNavigator.java | 3 +-- .../config.jelly | 0 .../help-apiUri.html | 0 .../help-appID.html | 0 .../help-privateKey.html | 0 .../plugins/github_branch_source/Messages.properties | 2 +- 8 files changed, 7 insertions(+), 10 deletions(-) rename src/main/java/org/jenkinsci/plugins/github_branch_source/{GitHubAppCredential.java => GitHubAppCredentials.java} (95%) rename src/main/resources/org/jenkinsci/plugins/github_branch_source/{GitHubAppCredential => GitHubAppCredentials}/config.jelly (100%) rename src/main/resources/org/jenkinsci/plugins/github_branch_source/{GitHubAppCredential => GitHubAppCredentials}/help-apiUri.html (100%) rename src/main/resources/org/jenkinsci/plugins/github_branch_source/{GitHubAppCredential => GitHubAppCredentials}/help-appID.html (100%) rename src/main/resources/org/jenkinsci/plugins/github_branch_source/{GitHubAppCredential => GitHubAppCredentials}/help-privateKey.html (100%) 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 cf501e5e0..37e919333 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 @@ -81,8 +81,6 @@ import org.kohsuke.github.RateLimitHandler; import org.kohsuke.github.extras.OkHttpConnector; -import static com.cloudbees.plugins.credentials.CredentialsMatchers.anyOf; -import static com.cloudbees.plugins.credentials.CredentialsMatchers.instanceOf; import static java.util.logging.Level.FINE; /** @@ -197,7 +195,7 @@ public static FormValidation checkScanCredentials(@CheckForNull Item context, St GitHub connector = Connector.connect(apiUri, credentials); try { try { - boolean githubAppAuthentication = credentials instanceof GitHubAppCredential; + boolean githubAppAuthentication = credentials instanceof GitHubAppCredentials; if (githubAppAuthentication) { int remaining = connector.getRateLimit().getRemaining(); return FormValidation.ok("GHApp verified, remaining rate limit: %d", remaining); diff --git a/src/main/java/org/jenkinsci/plugins/github_branch_source/GitHubAppCredential.java b/src/main/java/org/jenkinsci/plugins/github_branch_source/GitHubAppCredentials.java similarity index 95% rename from src/main/java/org/jenkinsci/plugins/github_branch_source/GitHubAppCredential.java rename to src/main/java/org/jenkinsci/plugins/github_branch_source/GitHubAppCredentials.java index 107fbb360..4ff804e2c 100644 --- a/src/main/java/org/jenkinsci/plugins/github_branch_source/GitHubAppCredential.java +++ b/src/main/java/org/jenkinsci/plugins/github_branch_source/GitHubAppCredentials.java @@ -27,7 +27,7 @@ import static org.jenkinsci.plugins.github_branch_source.GitHubSCMNavigator.DescriptorImpl.getPossibleApiUriItems; import static org.jenkinsci.plugins.github_branch_source.JwtHelper.createJWT; -public class GitHubAppCredential extends BaseStandardCredentials implements StandardUsernamePasswordCredentials { +public class GitHubAppCredentials extends BaseStandardCredentials implements StandardUsernamePasswordCredentials { private static final String ERROR_AUTHENTICATING_GITHUB_APP = "Couldn't authenticate with GitHub app ID %s"; @@ -41,7 +41,7 @@ public class GitHubAppCredential extends BaseStandardCredentials implements Stan @DataBoundConstructor @SuppressWarnings("unused") // by stapler - public GitHubAppCredential( + public GitHubAppCredentials( CredentialsScope scope, String id, @CheckForNull String description, @@ -130,7 +130,7 @@ public static class DescriptorImpl extends BaseStandardCredentialsDescriptor { */ @Override public String getDisplayName() { - return Messages.GitHubAppCredential_displayName(); + return Messages.GitHubAppCredentials_displayName(); } /** @@ -166,7 +166,7 @@ public FormValidation doTestConnection( @QueryParameter("apiUri") final String apiUri ) { - GitHubAppCredential gitHubAppCredential = new GitHubAppCredential( + GitHubAppCredentials gitHubAppCredential = new GitHubAppCredentials( CredentialsScope.GLOBAL, "test-id-not-being-saved", null, appID, Secret.fromString(privateKey) ); diff --git a/src/main/java/org/jenkinsci/plugins/github_branch_source/GitHubSCMNavigator.java b/src/main/java/org/jenkinsci/plugins/github_branch_source/GitHubSCMNavigator.java index 5d58b8cea..ede94cf55 100644 --- a/src/main/java/org/jenkinsci/plugins/github_branch_source/GitHubSCMNavigator.java +++ b/src/main/java/org/jenkinsci/plugins/github_branch_source/GitHubSCMNavigator.java @@ -25,7 +25,6 @@ package org.jenkinsci.plugins.github_branch_source; import com.cloudbees.jenkins.GitHubWebHook; -import com.cloudbees.jenkins.plugins.sshcredentials.SSHUserPrivateKey; import com.cloudbees.plugins.credentials.CredentialsNameProvider; import com.cloudbees.plugins.credentials.common.StandardCredentials; import com.cloudbees.plugins.credentials.common.StandardListBoxModel; @@ -934,7 +933,7 @@ public void visitSources(SCMSourceObserver observer) throws IOException, Interru SourceFactory sourceFactory = new SourceFactory(request); WitnessImpl witness = new WitnessImpl(listener); - boolean githubAppAuthentication = credentials instanceof GitHubAppCredential; + boolean githubAppAuthentication = credentials instanceof GitHubAppCredentials; if (!github.isAnonymous() && !githubAppAuthentication) { GHMyself myself; try { diff --git a/src/main/resources/org/jenkinsci/plugins/github_branch_source/GitHubAppCredential/config.jelly b/src/main/resources/org/jenkinsci/plugins/github_branch_source/GitHubAppCredentials/config.jelly similarity index 100% rename from src/main/resources/org/jenkinsci/plugins/github_branch_source/GitHubAppCredential/config.jelly rename to src/main/resources/org/jenkinsci/plugins/github_branch_source/GitHubAppCredentials/config.jelly diff --git a/src/main/resources/org/jenkinsci/plugins/github_branch_source/GitHubAppCredential/help-apiUri.html b/src/main/resources/org/jenkinsci/plugins/github_branch_source/GitHubAppCredentials/help-apiUri.html similarity index 100% rename from src/main/resources/org/jenkinsci/plugins/github_branch_source/GitHubAppCredential/help-apiUri.html rename to src/main/resources/org/jenkinsci/plugins/github_branch_source/GitHubAppCredentials/help-apiUri.html diff --git a/src/main/resources/org/jenkinsci/plugins/github_branch_source/GitHubAppCredential/help-appID.html b/src/main/resources/org/jenkinsci/plugins/github_branch_source/GitHubAppCredentials/help-appID.html similarity index 100% rename from src/main/resources/org/jenkinsci/plugins/github_branch_source/GitHubAppCredential/help-appID.html rename to src/main/resources/org/jenkinsci/plugins/github_branch_source/GitHubAppCredentials/help-appID.html diff --git a/src/main/resources/org/jenkinsci/plugins/github_branch_source/GitHubAppCredential/help-privateKey.html b/src/main/resources/org/jenkinsci/plugins/github_branch_source/GitHubAppCredentials/help-privateKey.html similarity index 100% rename from src/main/resources/org/jenkinsci/plugins/github_branch_source/GitHubAppCredential/help-privateKey.html rename to src/main/resources/org/jenkinsci/plugins/github_branch_source/GitHubAppCredentials/help-privateKey.html diff --git a/src/main/resources/org/jenkinsci/plugins/github_branch_source/Messages.properties b/src/main/resources/org/jenkinsci/plugins/github_branch_source/Messages.properties index b609ef7f7..e75daef7a 100644 --- a/src/main/resources/org/jenkinsci/plugins/github_branch_source/Messages.properties +++ b/src/main/resources/org/jenkinsci/plugins/github_branch_source/Messages.properties @@ -67,4 +67,4 @@ ExcludeArchivedRepositoriesTrait.displayName=Exclude archived repositories GitHubSCMNavigator.general=General GitHubSCMNavigator.withinRepository=Within repository -GitHubAppCredential.displayName=GitHub app \ No newline at end of file +GitHubAppCredentials.displayName=GitHub app \ No newline at end of file From 4ad286e241f0f3f5702c98600f6fac7ebb363444 Mon Sep 17 00:00:00 2001 From: Tim Jacomb Date: Thu, 6 Feb 2020 09:39:07 +0000 Subject: [PATCH 15/18] Add JCasC compatibility test --- ...bAppCredentialsJCasCCompatibilityTest.java | 91 +++++++++++++++++++ ...hub-app-jcasc-minimal-expected-export.yaml | 4 + .../github-app-jcasc-minimal.yaml | 9 ++ 3 files changed, 104 insertions(+) create mode 100644 src/test/java/org/jenkinsci/plugins/github_branch_source/GitHubAppCredentialsJCasCCompatibilityTest.java create mode 100644 src/test/resources/org/jenkinsci/plugins/github_branch_source/github-app-jcasc-minimal-expected-export.yaml create mode 100644 src/test/resources/org/jenkinsci/plugins/github_branch_source/github-app-jcasc-minimal.yaml diff --git a/src/test/java/org/jenkinsci/plugins/github_branch_source/GitHubAppCredentialsJCasCCompatibilityTest.java b/src/test/java/org/jenkinsci/plugins/github_branch_source/GitHubAppCredentialsJCasCCompatibilityTest.java new file mode 100644 index 000000000..008f95e5e --- /dev/null +++ b/src/test/java/org/jenkinsci/plugins/github_branch_source/GitHubAppCredentialsJCasCCompatibilityTest.java @@ -0,0 +1,91 @@ +package org.jenkinsci.plugins.github_branch_source; + +import com.cloudbees.plugins.credentials.Credentials; +import com.cloudbees.plugins.credentials.GlobalCredentialsConfiguration; +import com.cloudbees.plugins.credentials.SystemCredentialsProvider; +import com.cloudbees.plugins.credentials.casc.CredentialsRootConfigurator; +import com.cloudbees.plugins.credentials.domains.DomainCredentials; +import hudson.ExtensionList; +import io.jenkins.plugins.casc.ConfigurationContext; +import io.jenkins.plugins.casc.ConfiguratorRegistry; +import io.jenkins.plugins.casc.impl.configurators.GlobalConfigurationCategoryConfigurator; +import io.jenkins.plugins.casc.misc.ConfiguredWithCode; +import io.jenkins.plugins.casc.misc.EnvVarsRule; +import io.jenkins.plugins.casc.misc.JenkinsConfiguredWithCodeRule; +import io.jenkins.plugins.casc.model.CNode; +import io.jenkins.plugins.casc.model.Mapping; +import io.jenkins.plugins.casc.model.Sequence; +import java.util.List; +import java.util.Objects; +import jenkins.model.Jenkins; +import org.junit.ClassRule; +import org.junit.Test; +import org.junit.rules.RuleChain; + +import static io.jenkins.plugins.casc.misc.Util.toStringFromYamlFile; +import static io.jenkins.plugins.casc.misc.Util.toYamlString; +import static org.hamcrest.Matchers.instanceOf; +import static org.hamcrest.Matchers.is; +import static org.junit.Assert.assertThat; +import static org.jvnet.hudson.test.JenkinsMatchers.hasPlainText; + +public class GitHubAppCredentialsJCasCCompatibilityTest { + + @ConfiguredWithCode("github-app-jcasc-minimal.yaml") + public static JenkinsConfiguredWithCodeRule j = new JenkinsConfiguredWithCodeRule(); + + private static final String GITHUB_APP_KEY = "SomeString"; + + @ClassRule + public static RuleChain chain = RuleChain + .outerRule(new EnvVarsRule().set("GITHUB_APP_KEY", GITHUB_APP_KEY)) + .around(j); + + @Test + public void should_support_configuration_as_code() { + List domainCredentials = SystemCredentialsProvider.getInstance() + .getDomainCredentials(); + + assertThat(domainCredentials.size(), is(1)); + List credentials = domainCredentials.get(0).getCredentials(); + assertThat(credentials.size(), is(1)); + + Credentials credential = credentials.get(0); + assertThat(credential, instanceOf(GitHubAppCredentials.class)); + GitHubAppCredentials gitHubAppCredentials = (GitHubAppCredentials) credential; + + assertThat(gitHubAppCredentials.getAppID(), is("1111")); + assertThat(gitHubAppCredentials.getDescription(), is("GitHub app 1111")); + assertThat(gitHubAppCredentials.getId(), is("github-app")); + assertThat(gitHubAppCredentials.getPrivateKey(), hasPlainText(GITHUB_APP_KEY)); + } + + @Test + public void should_support_configuration_export() throws Exception { + Sequence credentials = getCredentials(); + CNode githubApp = credentials.get(0).asMapping().get("gitHubApp"); + + String exported = toYamlString(githubApp) + // replace secret with a constant value + .replaceAll("privateKey: .*", "privateKey: \"some-secret-value\""); + + String expected = toStringFromYamlFile(this, "github-app-jcasc-minimal-expected-export.yaml"); + + assertThat(exported, is(expected)); + } + + private Sequence getCredentials() throws Exception { + CredentialsRootConfigurator root = Jenkins.get() + .getExtensionList(CredentialsRootConfigurator.class).get(0); + + ConfiguratorRegistry registry = ConfiguratorRegistry.get(); + ConfigurationContext context = new ConfigurationContext(registry); + Mapping configNode = Objects + .requireNonNull(root.describe(root.getTargetComponent(context), context)).asMapping(); + Mapping domainCredentials = configNode + .get("system").asMapping().get("domainCredentials") + .asSequence() + .get(0).asMapping(); + return domainCredentials.get("credentials").asSequence(); + } +} diff --git a/src/test/resources/org/jenkinsci/plugins/github_branch_source/github-app-jcasc-minimal-expected-export.yaml b/src/test/resources/org/jenkinsci/plugins/github_branch_source/github-app-jcasc-minimal-expected-export.yaml new file mode 100644 index 000000000..ce7789c89 --- /dev/null +++ b/src/test/resources/org/jenkinsci/plugins/github_branch_source/github-app-jcasc-minimal-expected-export.yaml @@ -0,0 +1,4 @@ +appID: "1111" +description: "GitHub app 1111" +id: "github-app" +privateKey: "some-secret-value" diff --git a/src/test/resources/org/jenkinsci/plugins/github_branch_source/github-app-jcasc-minimal.yaml b/src/test/resources/org/jenkinsci/plugins/github_branch_source/github-app-jcasc-minimal.yaml new file mode 100644 index 000000000..6d3bc3ed9 --- /dev/null +++ b/src/test/resources/org/jenkinsci/plugins/github_branch_source/github-app-jcasc-minimal.yaml @@ -0,0 +1,9 @@ +credentials: + system: + domainCredentials: + - credentials: + - gitHubApp: + appID: "1111" + description: "GitHub app 1111" + id: "github-app" + privateKey: "${GITHUB_APP_KEY}" \ No newline at end of file From 91e4bf4cffe55ecdebaf19338ddf726d464c05be Mon Sep 17 00:00:00 2001 From: Tim Jacomb Date: Thu, 6 Feb 2020 14:48:54 +0000 Subject: [PATCH 16/18] Update the docs --- docs/github-app.adoc | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/docs/github-app.adoc b/docs/github-app.adoc index e62480213..fea6ac0fb 100644 --- a/docs/github-app.adoc +++ b/docs/github-app.adoc @@ -74,6 +74,8 @@ openssl pkcs8 -topk8 -inform PEM -outform PEM -in key-in-your-downloads-folder.p == Adding the Jenkins credential +=== UI + - From the Jenkins main page click 'Credentials' - Pick your credential store, normally `(global)` - Click 'Add credentials' @@ -83,10 +85,27 @@ Fill out the form: - Kind: GitHub app - ID: i.e. github-app- - App ID: the github app ID, it can be found in the 'About' section of your GitHub app in the general tab. +- API endpoint (optional, only required for GitHub enterprise this will only show up if a GitHub enterprise server is configured). - Key: click add, paste the contents of the converted private key - Passphrase: do not fill this field, it will be ignored - Click OK +=== link:https://github.com/jenkinsci/configuration-as-code-plugin[Configuration as Code Plugin] + +[source,yaml] +---- +credentials: + system: + domainCredentials: + - credentials: + - gitHubApp: + appID: "1111" + description: "GitHub app" + id: "github-app" + # apiUri: https://my-custom-github-enterprise.com/api/v3 # optional only required for GitHub enterprise + privateKey: "${GITHUB_APP_KEY}" +---- + == Configuring the github organization folder See the link:https://docs.cloudbees.com/docs/admin-resources/latest/plugins/github-branch-source[main documentation] @@ -94,6 +113,8 @@ for how to create a GitHub folder. - Load the folders configuration page - Select the GitHub app credentials in the 'Credentials field drop down +- If you are using GitHub enterprise make sure the API url is set to your server, +(note you currently need to set the API url on both the credential and the job). After selecting the credential you should see: From ff0327a40b7847230856c6de6c32bb9611016d64 Mon Sep 17 00:00:00 2001 From: Tim Jacomb Date: Thu, 6 Feb 2020 16:31:43 +0000 Subject: [PATCH 17/18] Update docs/github-app.adoc Co-Authored-By: Olivier Jacques --- docs/github-app.adoc | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/github-app.adoc b/docs/github-app.adoc index fea6ac0fb..3ac990e9e 100644 --- a/docs/github-app.adoc +++ b/docs/github-app.adoc @@ -47,7 +47,7 @@ The only fields you need to fill out (currently) are: Permissions this plugin uses: - Commit statuses - Read and Write -- Contents: Read-only (to read the Jenkinsfile) +- Contents: Read-only (to read the `Jenkinsfile` and the repository content during `git fetch`). You may need "Read & write" to update the repository such as tagging releases - Metadata: Read-only - Pull requests: Read-only - Webhooks (optional) - If you want the plugin to manage webhooks for you, Read and Write From d33000aec5cf9758fba59ab8bcb3829894f85eea Mon Sep 17 00:00:00 2001 From: Tim Jacomb Date: Mon, 17 Feb 2020 20:17:33 +0000 Subject: [PATCH 18/18] Apply suggestions from code review Co-Authored-By: Oleg Nenashev --- README.md | 2 +- .../github_branch_source/GitHubAppCredentials/config.jelly | 2 +- .../github_branch_source/GitHubAppCredentials/help-apiUri.html | 2 +- .../github_branch_source/GitHubAppCredentials/help-appID.html | 2 +- .../GitHubAppCredentials/help-privateKey.html | 2 +- .../jenkinsci/plugins/github_branch_source/Messages.properties | 2 +- .../jenkinsci/plugins/github_branch_source/JwtHelperTest.java | 2 +- 7 files changed, 7 insertions(+), 7 deletions(-) diff --git a/README.md b/README.md index 96c3259d7..6c7b3957b 100644 --- a/README.md +++ b/README.md @@ -12,7 +12,7 @@ GitHub users or organizations. Complete documentation is ### Guides -* [GitHub app authentication](docs/github-app.adoc) +* [GitHub App authentication](docs/github-app.adoc) * [Extension points provided by this plugin](docs/implementation.adoc) ## Extension plugins diff --git a/src/main/resources/org/jenkinsci/plugins/github_branch_source/GitHubAppCredentials/config.jelly b/src/main/resources/org/jenkinsci/plugins/github_branch_source/GitHubAppCredentials/config.jelly index 7544d9d58..5efc33569 100644 --- a/src/main/resources/org/jenkinsci/plugins/github_branch_source/GitHubAppCredentials/config.jelly +++ b/src/main/resources/org/jenkinsci/plugins/github_branch_source/GitHubAppCredentials/config.jelly @@ -27,4 +27,4 @@ - \ No newline at end of file + diff --git a/src/main/resources/org/jenkinsci/plugins/github_branch_source/GitHubAppCredentials/help-apiUri.html b/src/main/resources/org/jenkinsci/plugins/github_branch_source/GitHubAppCredentials/help-apiUri.html index 72fce8569..d2a930aa1 100644 --- a/src/main/resources/org/jenkinsci/plugins/github_branch_source/GitHubAppCredentials/help-apiUri.html +++ b/src/main/resources/org/jenkinsci/plugins/github_branch_source/GitHubAppCredentials/help-apiUri.html @@ -1,3 +1,3 @@

GitHub API endpoint such as https://github.example.com/api/v3/. -

\ No newline at end of file +

diff --git a/src/main/resources/org/jenkinsci/plugins/github_branch_source/GitHubAppCredentials/help-appID.html b/src/main/resources/org/jenkinsci/plugins/github_branch_source/GitHubAppCredentials/help-appID.html index d85fa8314..ca33b4078 100644 --- a/src/main/resources/org/jenkinsci/plugins/github_branch_source/GitHubAppCredentials/help-appID.html +++ b/src/main/resources/org/jenkinsci/plugins/github_branch_source/GitHubAppCredentials/help-appID.html @@ -1,3 +1,3 @@

GitHub app ID, can be found on the App's settings, on the General page in the About section -

\ No newline at end of file +

diff --git a/src/main/resources/org/jenkinsci/plugins/github_branch_source/GitHubAppCredentials/help-privateKey.html b/src/main/resources/org/jenkinsci/plugins/github_branch_source/GitHubAppCredentials/help-privateKey.html index 00374b9d8..995e6f0fe 100644 --- a/src/main/resources/org/jenkinsci/plugins/github_branch_source/GitHubAppCredentials/help-privateKey.html +++ b/src/main/resources/org/jenkinsci/plugins/github_branch_source/GitHubAppCredentials/help-privateKey.html @@ -3,4 +3,4 @@

You can convert it with openssl pkcs8 -topk8 -inform PEM -outform PEM -in current-key.pem -out new-key.pem -nocrypt -

\ No newline at end of file +

diff --git a/src/main/resources/org/jenkinsci/plugins/github_branch_source/Messages.properties b/src/main/resources/org/jenkinsci/plugins/github_branch_source/Messages.properties index e75daef7a..25c888f76 100644 --- a/src/main/resources/org/jenkinsci/plugins/github_branch_source/Messages.properties +++ b/src/main/resources/org/jenkinsci/plugins/github_branch_source/Messages.properties @@ -67,4 +67,4 @@ ExcludeArchivedRepositoriesTrait.displayName=Exclude archived repositories GitHubSCMNavigator.general=General GitHubSCMNavigator.withinRepository=Within repository -GitHubAppCredentials.displayName=GitHub app \ No newline at end of file +GitHubAppCredentials.displayName=GitHub App 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 index 139ec5327..4fd37d0e8 100644 --- a/src/test/java/org/jenkinsci/plugins/github_branch_source/JwtHelperTest.java +++ b/src/test/java/org/jenkinsci/plugins/github_branch_source/JwtHelperTest.java @@ -125,4 +125,4 @@ private static PublicKey getPublicKeyFromString(final String key) throws General return kf.generatePublic(keySpecPKCS8); } -} \ No newline at end of file +}