diff --git a/README.md b/README.md index c27c8ff9a..6c7b3957b 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..3ac990e9e --- /dev/null +++ b/docs/github-app.adoc @@ -0,0 +1,140 @@ += 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 +- 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 + +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 +- 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 + + +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 +---- + +== Install the GitHub app + +- From the install app section of newly created app, install the app to your organization. + +== Adding the Jenkins credential + +=== UI + +- From the Jenkins main page click 'Credentials' +- Pick your credential store, normally `(global)` +- Click 'Add credentials' + +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] +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: + +[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] diff --git a/pom.xml b/pom.xml index dbbab0a0f..058a38260 100644 --- a/pom.xml +++ b/pom.xml @@ -30,6 +30,7 @@ 2.2 true 1.35 + 0.10.5 @@ -67,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 @@ -79,6 +86,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..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 @@ -76,11 +76,8 @@ 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.GitHub; import org.kohsuke.github.GitHubBuilder; -import org.kohsuke.github.HttpConnector; import org.kohsuke.github.RateLimitHandler; import org.kohsuke.github.extras.OkHttpConnector; @@ -198,13 +195,23 @@ public static FormValidation checkScanCredentials(@CheckForNull Item context, St GitHub connector = Connector.connect(apiUri, credentials); try { try { + boolean githubAppAuthentication = credentials instanceof GitHubAppCredentials; + if (githubAppAuthentication) { + int remaining = connector.getRateLimit().getRemaining(); + return FormValidation.ok("GHApp verified, remaining rate limit: %d", remaining); + } + return FormValidation.ok("User %s", connector.getMyself().getLogin()); - } catch (IOException e){ - return FormValidation.error("Invalid credentials"); + } catch (Exception e) { + return FormValidation.error("Invalid credentials: %s", e.getMessage()); } } finally { Connector.release(connector); } + } catch (IllegalArgumentException | InvalidPrivateKeyException e) { + 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[]{ @@ -512,7 +519,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/GitHubAppCredentials.java b/src/main/java/org/jenkinsci/plugins/github_branch_source/GitHubAppCredentials.java new file mode 100644 index 000000000..4ff804e2c --- /dev/null +++ b/src/main/java/org/jenkinsci/plugins/github_branch_source/GitHubAppCredentials.java @@ -0,0 +1,184 @@ +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.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 GitHubAppCredentials 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 apiUri; + + @DataBoundConstructor + @SuppressWarnings("unused") // by stapler + public GitHubAppCredentials( + 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 getApiUri() { + return apiUri; + } + + @DataBoundSetter + public void setApiUri(String apiUri) { + this.apiUri = apiUri; + } + + @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(apiUri) == null) { + apiUri = "https://api.github.com"; + } + + String appInstallationToken = generateAppInstallationToken(appID, privateKey.getPlainText(), apiUri); + + 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.GitHubAppCredentials_displayName(); + } + + /** + * {@inheritDoc} + */ + @Override + 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 + + ) { + GitHubAppCredentials gitHubAppCredential = new GitHubAppCredentials( + 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 e4b2e1ea3..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 @@ -933,7 +933,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 GitHubAppCredentials; + if (!github.isAnonymous() && !githubAppAuthentication) { GHMyself myself; try { // Requires an authenticated access @@ -1414,6 +1415,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/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/GitHubAppCredentials/config.jelly b/src/main/resources/org/jenkinsci/plugins/github_branch_source/GitHubAppCredentials/config.jelly new file mode 100644 index 000000000..5efc33569 --- /dev/null +++ b/src/main/resources/org/jenkinsci/plugins/github_branch_source/GitHubAppCredentials/config.jelly @@ -0,0 +1,30 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + 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 new file mode 100644 index 000000000..d2a930aa1 --- /dev/null +++ b/src/main/resources/org/jenkinsci/plugins/github_branch_source/GitHubAppCredentials/help-apiUri.html @@ -0,0 +1,3 @@ +

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

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 new file mode 100644 index 000000000..ca33b4078 --- /dev/null +++ b/src/main/resources/org/jenkinsci/plugins/github_branch_source/GitHubAppCredentials/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 +

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 new file mode 100644 index 000000000..995e6f0fe --- /dev/null +++ b/src/main/resources/org/jenkinsci/plugins/github_branch_source/GitHubAppCredentials/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 +

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..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 @@ -66,3 +66,5 @@ ExcludeArchivedRepositoriesTrait.displayName=Exclude archived repositories GitHubSCMNavigator.general=General GitHubSCMNavigator.withinRepository=Within repository + +GitHubAppCredentials.displayName=GitHub App 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/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..4fd37d0e8 --- /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); + } +} 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