diff --git a/pom.xml b/pom.xml index 1b9cfa9d9..e9d2f08d9 100644 --- a/pom.xml +++ b/pom.xml @@ -42,6 +42,10 @@ github-api 1.122 + + org.jenkins-ci.plugins + credentials-binding + com.coravy.hudson.plugins.github github diff --git a/src/main/java/org/jenkinsci/plugins/github_branch_source/GitHubAppCredentials.java b/src/main/java/org/jenkinsci/plugins/github_branch_source/GitHubAppCredentials.java index cade19be3..1b9429ed0 100644 --- a/src/main/java/org/jenkinsci/plugins/github_branch_source/GitHubAppCredentials.java +++ b/src/main/java/org/jenkinsci/plugins/github_branch_source/GitHubAppCredentials.java @@ -20,7 +20,9 @@ import java.security.GeneralSecurityException; import java.time.Duration; import java.time.Instant; +import java.util.ArrayList; import java.util.List; +import java.util.Optional; import java.util.concurrent.TimeUnit; import java.util.logging.Level; import java.util.logging.Logger; @@ -48,7 +50,7 @@ public class GitHubAppCredentials extends BaseStandardCredentials private static final Logger LOGGER = Logger.getLogger(GitHubAppCredentials.class.getName()); private static final String ERROR_AUTHENTICATING_GITHUB_APP = - "Couldn't authenticate with GitHub app ID %s"; + "Couldn't authenticate with GitHub app ID: %s for owner: %s"; private static final String NOT_INSTALLED = ", has it been installed to your GitHub organisation / user?"; @@ -75,7 +77,7 @@ public class GitHubAppCredentials extends BaseStandardCredentials private String owner; - private transient AppInstallationToken cachedToken; + private transient List cachedTokens; @DataBoundConstructor @SuppressWarnings("unused") // by stapler @@ -204,12 +206,12 @@ static AppInstallationToken generateAppInstallationToken( app = gitHubApp.getApp(); } catch (IOException e) { throw new IllegalArgumentException( - String.format(ERROR_AUTHENTICATING_GITHUB_APP, appId), e); + String.format(ERROR_AUTHENTICATING_GITHUB_APP, appId, owner), e); } List appInstallations = app.listInstallations().asList(); if (appInstallations.isEmpty()) { - throw new IllegalArgumentException(String.format(ERROR_NOT_INSTALLED, appId)); + throw new IllegalArgumentException(String.format(ERROR_NOT_INSTALLED, appId, owner)); } GHAppInstallation appInstallation; if (appInstallations.size() == 1) { @@ -217,7 +219,8 @@ static AppInstallationToken generateAppInstallationToken( } else { appInstallation = appInstallations.stream() - .filter(installation -> installation.getAccount().getLogin().equals(owner)) + .filter( + installation -> installation.getAccount().getLogin().equalsIgnoreCase(owner)) .findAny() .orElseThrow( () -> @@ -230,7 +233,8 @@ static AppInstallationToken generateAppInstallationToken( long expiration = getExpirationSeconds(appInstallationToken); AppInstallationToken token = - new AppInstallationToken(Secret.fromString(appInstallationToken.getToken()), expiration); + new AppInstallationToken( + owner, Secret.fromString(appInstallationToken.getToken()), expiration); LOGGER.log(Level.FINER, "Generated App Installation Token for app ID {0}", appId); LOGGER.log( Level.FINEST, @@ -265,14 +269,32 @@ String actualApiUri() { return Util.fixEmpty(apiUri) == null ? "https://api.github.com" : apiUri; } - private AppInstallationToken getToken(GitHub gitHub) { + private AppInstallationToken getToken(final GitHub gitHub) { + return getToken(gitHub, owner); + } + + private AppInstallationToken getToken(final GitHub gitHub, final String owner) { + AppInstallationToken cachedToken = null; synchronized (this) { try { + if (cachedTokens == null) { + cachedTokens = new ArrayList<>(); + } else { + Optional tempToken = + cachedTokens.stream() + .filter(token -> token.getOwner().equalsIgnoreCase(owner)) + .findAny(); + if (tempToken.isPresent()) { + cachedToken = tempToken.get(); + } + } if (cachedToken == null || cachedToken.isStale()) { LOGGER.log(Level.FINE, "Generating App Installation Token for app ID {0}", appID); cachedToken = generateAppInstallationToken( gitHub, appID, privateKey.getPlainText(), actualApiUri(), owner); + cachedTokens.removeIf(token -> token.getOwner().equalsIgnoreCase(owner)); + cachedTokens.add(cachedToken); LOGGER.log(Level.FINER, "Retrieved GitHub App Installation Token for app ID {0}", appID); } } catch (Exception e) { @@ -303,6 +325,16 @@ public Secret getPassword() { return this.getToken(null).getToken(); } + /** + * Returns the Password. + * + * @param owner, owner of the repo. + * @return the password + */ + public Secret getPassword(final String owner) { + return this.getToken(null, owner).getToken(); + } + /** {@inheritDoc} */ @NonNull @Override @@ -310,9 +342,9 @@ public String getUsername() { return appID; } - private AppInstallationToken getCachedToken() { + private List getCachedTokens() { synchronized (this) { - return cachedToken; + return cachedTokens; } } @@ -355,6 +387,7 @@ static class AppInstallationToken implements Serializable { GitHubAppCredentials.class.getName() + ".NOT_STALE_MINIMUM_SECONDS", Duration.ofMinutes(1).getSeconds()); + private final String owner; private final Secret token; private final long expirationEpochSeconds; private final long staleEpochSeconds; @@ -371,7 +404,7 @@ static class AppInstallationToken implements Serializable { * @param token the token string * @param expirationEpochSeconds the time in epoch seconds that this token will expire */ - public AppInstallationToken(Secret token, long expirationEpochSeconds) { + public AppInstallationToken(String owner, Secret token, long expirationEpochSeconds) { long now = Instant.now().getEpochSecond(); long secondsUntilExpiration = expirationEpochSeconds - now; @@ -393,6 +426,7 @@ public AppInstallationToken(Secret token, long expirationEpochSeconds) { LOGGER.log(Level.FINER, "Token will become stale after " + secondsUntilStale + " seconds"); + this.owner = owner; this.token = token; this.expirationEpochSeconds = expirationEpochSeconds; this.staleEpochSeconds = now + secondsUntilStale; @@ -402,6 +436,10 @@ public Secret getToken() { return token; } + public String getOwner() { + return owner; + } + /** * Whether a token is stale and should be replaced with a new token. * @@ -448,13 +486,14 @@ private static final class DelegatingGitHubAppCredentials extends BaseStandardCr implements StandardUsernamePasswordCredentials { private final String appID; + private final String owner; /** * An encrypted form of all data needed to refresh the token. Used to prevent {@link GetToken} * from being abused by compromised build agents. */ private final String tokenRefreshData; - private AppInstallationToken cachedToken; + private List cachedTokens; private transient Channel ch; @@ -462,11 +501,11 @@ private static final class DelegatingGitHubAppCredentials extends BaseStandardCr super(onMaster.getScope(), onMaster.getId(), onMaster.getDescription()); JenkinsJVM.checkJenkinsJVM(); appID = onMaster.appID; + owner = onMaster.owner; JSONObject j = new JSONObject(); j.put("appID", appID); j.put("privateKey", onMaster.privateKey.getPlainText()); j.put("apiUri", onMaster.actualApiUri()); - j.put("owner", onMaster.owner); tokenRefreshData = Secret.fromString(j.toString()).getEncryptedValue(); // Check token is valid before sending it to the agent. @@ -490,7 +529,7 @@ private static final class DelegatingGitHubAppCredentials extends BaseStandardCr e); } - cachedToken = onMaster.getCachedToken(); + cachedTokens = onMaster.getCachedTokens(); } private Object readResolve() { @@ -509,14 +548,32 @@ public String getUsername() { @Override public Secret getPassword() { + return getPassword(owner); + } + + public Secret getPassword(final String owner) { JenkinsJVM.checkNotJenkinsJVM(); try { synchronized (this) { + AppInstallationToken cachedToken = null; try { + if (cachedTokens == null) { + cachedTokens = new ArrayList<>(); + } else { + Optional tempToken = + cachedTokens.stream() + .filter(token -> token.getOwner().equalsIgnoreCase(owner)) + .findAny(); + if (tempToken.isPresent()) { + cachedToken = tempToken.get(); + } + } if (cachedToken == null || cachedToken.isStale()) { LOGGER.log( Level.FINE, "Generating App Installation Token for app ID {0} on agent", appID); - cachedToken = ch.call(new GetToken(tokenRefreshData)); + cachedToken = ch.call(new GetToken(owner, tokenRefreshData)); + cachedTokens.removeIf(token -> token.getOwner().equalsIgnoreCase(owner)); + cachedTokens.add(cachedToken); LOGGER.log( Level.FINER, "Retrieved GitHub App Installation Token for app ID {0} on agent", @@ -563,9 +620,11 @@ public Secret getPassword() { private static final class GetToken extends SlaveToMasterCallable { + private final String owner; private final String data; - GetToken(String data) { + GetToken(String owner, String data) { + this.owner = owner; this.data = data; } @@ -583,7 +642,7 @@ public AppInstallationToken call() throws RuntimeException { (String) fields.get("appID"), (String) fields.get("privateKey"), (String) fields.get("apiUri"), - (String) fields.get("owner")); + owner); LOGGER.log( Level.FINER, "Retrieved GitHub App Installation Token for app ID {0} for agent", @@ -665,7 +724,8 @@ public FormValidation doTestConnection( Connector.release(connect); } } catch (Exception e) { - return FormValidation.error(e, String.format(ERROR_AUTHENTICATING_GITHUB_APP, appID)); + return FormValidation.error( + e, String.format(ERROR_AUTHENTICATING_GITHUB_APP, appID, owner)); } } } diff --git a/src/main/java/org/jenkinsci/plugins/github_branch_source/GitHubAppCredentialsBinding.java b/src/main/java/org/jenkinsci/plugins/github_branch_source/GitHubAppCredentialsBinding.java new file mode 100644 index 000000000..77b48baba --- /dev/null +++ b/src/main/java/org/jenkinsci/plugins/github_branch_source/GitHubAppCredentialsBinding.java @@ -0,0 +1,130 @@ +package org.jenkinsci.plugins.github_branch_source; + +import edu.umd.cs.findbugs.annotations.NonNull; +import edu.umd.cs.findbugs.annotations.Nullable; +import hudson.Extension; +import hudson.FilePath; +import hudson.Launcher; +import hudson.model.Run; +import hudson.model.TaskListener; +import java.io.IOException; +import java.util.Arrays; +import java.util.HashMap; +import java.util.HashSet; +import java.util.Map; +import java.util.Set; +import javax.annotation.Nonnull; +import org.apache.commons.lang.StringUtils; +import org.jenkinsci.Symbol; +import org.jenkinsci.plugins.credentialsbinding.BindingDescriptor; +import org.jenkinsci.plugins.credentialsbinding.MultiBinding; +import org.kohsuke.stapler.DataBoundConstructor; + +/** @author Naresh Rayapati */ +public class GitHubAppCredentialsBinding extends MultiBinding { + + private static final String DEFAULT_GITHUB_APP_ID_VARIABLE_NAME = "GITHUB_APP_ID"; + private static final String DEFAULT_GITHUB_TOKEN_VARIABLE_NAME = "GITHUB_TOKEN"; + private static final String DEFAULT_GITHUB_OWNER_VARIABLE_NAME = "GITHUB_OWNER"; + + @NonNull private final String appIdVariable; + + @NonNull private final String tokenVariable; + + @NonNull private final String ownerVariable; + + private final String owner; + + /** + * @param appIdVariable if {@code null}, {@value DEFAULT_GITHUB_APP_ID_VARIABLE_NAME} will be + * used. + * @param tokenVariable if {@code null}, {@value DEFAULT_GITHUB_TOKEN_VARIABLE_NAME} will be used. + * @param owner if {@code null}, that default value configured at credentials level will be used + * if any. + * @param credentialsId identifier which should be referenced when accessing the credentials from + * a job/pipeline. + */ + @DataBoundConstructor + public GitHubAppCredentialsBinding( + @Nullable String appIdVariable, + @Nullable String tokenVariable, + @Nullable String ownerVariable, + @Nullable String owner, + String credentialsId) { + super(credentialsId); + this.appIdVariable = + StringUtils.defaultIfBlank(appIdVariable, DEFAULT_GITHUB_APP_ID_VARIABLE_NAME); + this.tokenVariable = + StringUtils.defaultIfBlank(tokenVariable, DEFAULT_GITHUB_TOKEN_VARIABLE_NAME); + this.ownerVariable = + StringUtils.defaultIfBlank(ownerVariable, DEFAULT_GITHUB_OWNER_VARIABLE_NAME); + this.owner = owner; + } + + @NonNull + public String getAppIdVariable() { + return appIdVariable; + } + + @NonNull + public String getTokenVariable() { + return tokenVariable; + } + + @NonNull + public String getOwnerVariable() { + return ownerVariable; + } + + public String getOwner() { + return owner; + } + + @Override + protected Class type() { + return GitHubAppCredentials.class; + } + + @Override + public MultiEnvironment bind( + @Nonnull Run build, FilePath workspace, Launcher launcher, TaskListener listener) + throws IOException { + GitHubAppCredentials credentials = getCredentials(build); + Map m = new HashMap(); + m.put(appIdVariable, credentials.getAppID()); + if (StringUtils.isNotEmpty(owner)) { + m.put(tokenVariable, credentials.getPassword(owner).getPlainText()); + m.put(ownerVariable, owner); + } else { + m.put(tokenVariable, credentials.getPassword().getPlainText()); + m.put(ownerVariable, credentials.getOwner()); + } + + return new MultiEnvironment(m); + } + + @Override + public Set variables() { + return new HashSet(Arrays.asList(appIdVariable, tokenVariable, ownerVariable)); + } + + @Symbol("gitHubApp") + @Extension + public static class DescriptorImpl extends BindingDescriptor { + + @Override + protected Class type() { + return GitHubAppCredentials.class; + } + + @Override + public String getDisplayName() { + return "GitHub App Credentials"; + } + + @Override + public boolean requiresWorkspace() { + return false; + } + } +} diff --git a/src/main/resources/org/jenkinsci/plugins/github_branch_source/GitHubAppCredentialsBinding/config-variables.jelly b/src/main/resources/org/jenkinsci/plugins/github_branch_source/GitHubAppCredentialsBinding/config-variables.jelly new file mode 100644 index 000000000..09103ad58 --- /dev/null +++ b/src/main/resources/org/jenkinsci/plugins/github_branch_source/GitHubAppCredentialsBinding/config-variables.jelly @@ -0,0 +1,16 @@ + + + + + + + + + + + + + + + + diff --git a/src/main/resources/org/jenkinsci/plugins/github_branch_source/GitHubAppCredentialsBinding/help-appIdVariable.html b/src/main/resources/org/jenkinsci/plugins/github_branch_source/GitHubAppCredentialsBinding/help-appIdVariable.html new file mode 100644 index 000000000..812f609e0 --- /dev/null +++ b/src/main/resources/org/jenkinsci/plugins/github_branch_source/GitHubAppCredentialsBinding/help-appIdVariable.html @@ -0,0 +1,3 @@ +
+ Environment variable name for the GitHub App Id. If empty, GITHUB_APP_ID will be used. +
diff --git a/src/main/resources/org/jenkinsci/plugins/github_branch_source/GitHubAppCredentialsBinding/help-owner.html b/src/main/resources/org/jenkinsci/plugins/github_branch_source/GitHubAppCredentialsBinding/help-owner.html new file mode 100644 index 000000000..43a8234f9 --- /dev/null +++ b/src/main/resources/org/jenkinsci/plugins/github_branch_source/GitHubAppCredentialsBinding/help-owner.html @@ -0,0 +1,3 @@ +
+ Input to override the default owner name configured for the given GitHub App credentials. +
diff --git a/src/main/resources/org/jenkinsci/plugins/github_branch_source/GitHubAppCredentialsBinding/help-ownerVariable.html b/src/main/resources/org/jenkinsci/plugins/github_branch_source/GitHubAppCredentialsBinding/help-ownerVariable.html new file mode 100644 index 000000000..bdf3679dc --- /dev/null +++ b/src/main/resources/org/jenkinsci/plugins/github_branch_source/GitHubAppCredentialsBinding/help-ownerVariable.html @@ -0,0 +1,3 @@ +
+ Environment variable name for the GitHub App Owner. If empty, GITHUB_OWNER will be used. +
diff --git a/src/main/resources/org/jenkinsci/plugins/github_branch_source/GitHubAppCredentialsBinding/help-tokenVariable.html b/src/main/resources/org/jenkinsci/plugins/github_branch_source/GitHubAppCredentialsBinding/help-tokenVariable.html new file mode 100644 index 000000000..ad1fe7b18 --- /dev/null +++ b/src/main/resources/org/jenkinsci/plugins/github_branch_source/GitHubAppCredentialsBinding/help-tokenVariable.html @@ -0,0 +1,3 @@ +
+ Environment variable name for the GitHub token for given App. If empty, GITHUB_TOKEN will be used. +
diff --git a/src/main/resources/org/jenkinsci/plugins/github_branch_source/GitHubAppCredentialsBinding/help.html b/src/main/resources/org/jenkinsci/plugins/github_branch_source/GitHubAppCredentialsBinding/help.html new file mode 100644 index 000000000..3423a53d0 --- /dev/null +++ b/src/main/resources/org/jenkinsci/plugins/github_branch_source/GitHubAppCredentialsBinding/help.html @@ -0,0 +1,3 @@ +
+ Sets GitHup App Id, Token and Owner from the given in the credentials. Owner is input to override the default configured with credentials. +
diff --git a/src/test/java/org/jenkinsci/plugins/github_branch_source/GithubAppCredentialsAppInstallationTokenTest.java b/src/test/java/org/jenkinsci/plugins/github_branch_source/GithubAppCredentialsAppInstallationTokenTest.java index 814b738dc..1790be512 100644 --- a/src/test/java/org/jenkinsci/plugins/github_branch_source/GithubAppCredentialsAppInstallationTokenTest.java +++ b/src/test/java/org/jenkinsci/plugins/github_branch_source/GithubAppCredentialsAppInstallationTokenTest.java @@ -17,8 +17,9 @@ public void testAppInstallationTokenStale() throws Exception { long now; now = Instant.now().getEpochSecond(); + String owner = "test"; Secret secret = Secret.fromString("secret-token"); - token = new GitHubAppCredentials.AppInstallationToken(secret, now); + token = new GitHubAppCredentials.AppInstallationToken(owner, secret, now); assertThat(token.isStale(), is(false)); assertThat( token.getTokenStaleEpochSeconds(), @@ -27,7 +28,7 @@ public void testAppInstallationTokenStale() throws Exception { now = Instant.now().getEpochSecond(); token = new GitHubAppCredentials.AppInstallationToken( - secret, now + Duration.ofMinutes(15).getSeconds()); + owner, secret, now + Duration.ofMinutes(15).getSeconds()); assertThat(token.isStale(), is(false)); assertThat( token.getTokenStaleEpochSeconds(), @@ -36,6 +37,7 @@ public void testAppInstallationTokenStale() throws Exception { now = Instant.now().getEpochSecond(); token = new GitHubAppCredentials.AppInstallationToken( + owner, secret, now + GitHubAppCredentials.AppInstallationToken.STALE_BEFORE_EXPIRATION_SECONDS + 2); assertThat(token.isStale(), is(false)); @@ -46,6 +48,7 @@ public void testAppInstallationTokenStale() throws Exception { now = Instant.now().getEpochSecond(); token = new GitHubAppCredentials.AppInstallationToken( + owner, secret, now + GitHubAppCredentials.AppInstallationToken.STALE_BEFORE_EXPIRATION_SECONDS @@ -57,7 +60,7 @@ public void testAppInstallationTokenStale() throws Exception { now = Instant.now().getEpochSecond(); token = new GitHubAppCredentials.AppInstallationToken( - secret, now + Duration.ofMinutes(90).getSeconds()); + owner, secret, now + Duration.ofMinutes(90).getSeconds()); assertThat(token.isStale(), is(false)); assertThat( token.getTokenStaleEpochSeconds(), @@ -69,7 +72,7 @@ public void testAppInstallationTokenStale() throws Exception { GitHubAppCredentials.AppInstallationToken.NOT_STALE_MINIMUM_SECONDS = -10; now = Instant.now().getEpochSecond(); - token = new GitHubAppCredentials.AppInstallationToken(secret, now); + token = new GitHubAppCredentials.AppInstallationToken(owner, secret, now); assertThat(token.isStale(), is(false)); assertThat(token.getTokenStaleEpochSeconds(), equalTo(now + 1));