diff --git a/pom.xml b/pom.xml index 39b533293c..50a1f8af1e 100644 --- a/pom.xml +++ b/pom.xml @@ -45,6 +45,7 @@ 0.25 false + 0.11.2 @@ -489,20 +490,20 @@ io.jsonwebtoken jjwt-api - 0.11.2 - test + ${jjwt.suite.version} + true io.jsonwebtoken jjwt-impl - 0.11.2 - test + ${jjwt.suite.version} + true io.jsonwebtoken jjwt-jackson - 0.11.2 - test + ${jjwt.suite.version} + true com.squareup.okio diff --git a/src/main/java/org/kohsuke/github/GHEvent.java b/src/main/java/org/kohsuke/github/GHEvent.java index 0fb2834cb8..481dfa27bd 100644 --- a/src/main/java/org/kohsuke/github/GHEvent.java +++ b/src/main/java/org/kohsuke/github/GHEvent.java @@ -12,6 +12,7 @@ public enum GHEvent { CHECK_RUN, CHECK_SUITE, + CODE_SCANNING_ALERT, COMMIT_COMMENT, CONTENT_REFERENCE, CREATE, diff --git a/src/main/java/org/kohsuke/github/GitHub.java b/src/main/java/org/kohsuke/github/GitHub.java index db00703af5..ef070b4423 100644 --- a/src/main/java/org/kohsuke/github/GitHub.java +++ b/src/main/java/org/kohsuke/github/GitHub.java @@ -26,6 +26,7 @@ import com.fasterxml.jackson.databind.ObjectReader; import com.fasterxml.jackson.databind.ObjectWriter; import com.infradna.tool.bridge_method_injector.WithBridgeMethods; +import org.kohsuke.github.authorization.AuthorizationProvider; import org.kohsuke.github.internal.Previews; import java.io.*; @@ -94,39 +95,112 @@ public class GitHub { * "http://ghe.acme.com/api/v3". Note that GitHub Enterprise has /api/v3 in the URL. For * historical reasons, this parameter still accepts the bare domain name, but that's considered * deprecated. Password is also considered deprecated as it is no longer required for api usage. - * @param login - * The user ID on GitHub that you are logging in as. Can be omitted if the OAuth token is provided or if - * logging in anonymously. Specifying this would save one API call. - * @param oauthAccessToken - * Secret OAuth token. - * @param password - * User's password. Always used in conjunction with the {@code login} parameter * @param connector - * HttpConnector to use. Pass null to use default connector. + * a connector + * @param rateLimitHandler + * rateLimitHandler + * @param abuseLimitHandler + * abuseLimitHandler + * @param rateLimitChecker + * rateLimitChecker + * @param authorizationProvider + * a authorization provider */ GitHub(String apiUrl, - String login, - String oauthAccessToken, - String jwtToken, - String password, HttpConnector connector, RateLimitHandler rateLimitHandler, AbuseLimitHandler abuseLimitHandler, - GitHubRateLimitChecker rateLimitChecker) throws IOException { + GitHubRateLimitChecker rateLimitChecker, + AuthorizationProvider authorizationProvider) throws IOException { + if (authorizationProvider instanceof DependentAuthorizationProvider) { + ((DependentAuthorizationProvider) authorizationProvider).bind(this); + } + this.client = new GitHubHttpUrlConnectionClient(apiUrl, - login, - oauthAccessToken, - jwtToken, - password, connector, rateLimitHandler, abuseLimitHandler, rateLimitChecker, - (myself) -> setMyself(myself)); + (myself) -> setMyself(myself), + authorizationProvider); users = new ConcurrentHashMap<>(); orgs = new ConcurrentHashMap<>(); } + private GitHub(GitHubClient client) { + this.client = client; + users = new ConcurrentHashMap<>(); + orgs = new ConcurrentHashMap<>(); + } + + public static abstract class DependentAuthorizationProvider implements AuthorizationProvider { + + private GitHub baseGitHub; + private GitHub gitHub; + private final AuthorizationProvider authorizationProvider; + + /** + * An AuthorizationProvider that requires an authenticated GitHub instance to provide its authorization. + * + * @param authorizationProvider + * A authorization provider to be used when refreshing this authorization provider. + */ + @BetaApi + @Deprecated + protected DependentAuthorizationProvider(AuthorizationProvider authorizationProvider) { + this.authorizationProvider = authorizationProvider; + } + + /** + * Binds this authorization provider to a github instance. + * + * Only needs to be implemented by dynamic credentials providers that use a github instance in order to refresh. + * + * @param github + * The github instance to be used for refreshing dynamic credentials + */ + synchronized void bind(GitHub github) { + if (baseGitHub != null) { + throw new IllegalStateException("Already bound to another GitHub instance."); + } + this.baseGitHub = github; + } + + protected synchronized final GitHub gitHub() { + if (gitHub == null) { + gitHub = new GitHub.AuthorizationRefreshGitHubWrapper(this.baseGitHub, authorizationProvider); + } + return gitHub; + } + } + + private static class AuthorizationRefreshGitHubWrapper extends GitHub { + + private final AuthorizationProvider authorizationProvider; + + AuthorizationRefreshGitHubWrapper(GitHub github, AuthorizationProvider authorizationProvider) { + super(github.client); + this.authorizationProvider = authorizationProvider; + + // no dependent authorization providers nest like this currently, but they might in future + if (authorizationProvider instanceof DependentAuthorizationProvider) { + ((DependentAuthorizationProvider) authorizationProvider).bind(this); + } + } + + @Nonnull + @Override + Requester createRequest() { + try { + // Override + return super.createRequest().setHeader("Authorization", authorizationProvider.getEncodedAuthorization()) + .rateLimit(RateLimitTarget.NONE); + } catch (IOException e) { + throw new GHException("Failed to create requester to refresh credentials", e); + } + } + } + /** * Obtains the credential from "~/.github" or from the System Environment Properties. * diff --git a/src/main/java/org/kohsuke/github/GitHubBuilder.java b/src/main/java/org/kohsuke/github/GitHubBuilder.java index e731238f4e..94ba46a0e4 100644 --- a/src/main/java/org/kohsuke/github/GitHubBuilder.java +++ b/src/main/java/org/kohsuke/github/GitHubBuilder.java @@ -1,6 +1,8 @@ package org.kohsuke.github; import org.apache.commons.io.IOUtils; +import org.kohsuke.github.authorization.AuthorizationProvider; +import org.kohsuke.github.authorization.ImmutableAuthorizationProvider; import org.kohsuke.github.extras.ImpatientHttpConnector; import java.io.File; @@ -24,16 +26,13 @@ public class GitHubBuilder implements Cloneable { // default scoped so unit tests can read them. /* private */ String endpoint = GitHubClient.GITHUB_URL; - /* private */ String user; - /* private */ String password; - /* private */ String oauthToken; - /* private */ String jwtToken; private HttpConnector connector; private RateLimitHandler rateLimitHandler = RateLimitHandler.WAIT; private AbuseLimitHandler abuseLimitHandler = AbuseLimitHandler.WAIT; private GitHubRateLimitChecker rateLimitChecker = new GitHubRateLimitChecker(); + /* private */ AuthorizationProvider authorizationProvider = AuthorizationProvider.ANONYMOUS; /** * Instantiates a new Git hub builder. @@ -61,13 +60,13 @@ static GitHubBuilder fromCredentials() throws IOException { builder = fromEnvironment(); - if (builder.oauthToken != null || builder.user != null || builder.jwtToken != null) + if (builder.authorizationProvider != null) return builder; try { builder = fromPropertyFile(); - if (builder.oauthToken != null || builder.user != null || builder.jwtToken != null) + if (builder.authorizationProvider != null) return builder; } catch (FileNotFoundException e) { // fall through @@ -215,9 +214,20 @@ public static GitHubBuilder fromPropertyFile(String propertyFileName) throws IOE */ public static GitHubBuilder fromProperties(Properties props) { GitHubBuilder self = new GitHubBuilder(); - self.withOAuthToken(props.getProperty("oauth"), props.getProperty("login")); - self.withJwtToken(props.getProperty("jwt")); - self.withPassword(props.getProperty("login"), props.getProperty("password")); + String oauth = props.getProperty("oauth"); + String jwt = props.getProperty("jwt"); + String login = props.getProperty("login"); + String password = props.getProperty("password"); + + if (oauth != null) { + self.withOAuthToken(oauth, login); + } + if (jwt != null) { + self.withJwtToken(jwt); + } + if (password != null) { + self.withPassword(login, password); + } self.withEndpoint(props.getProperty("endpoint", GitHubClient.GITHUB_URL)); return self; } @@ -247,9 +257,7 @@ public GitHubBuilder withEndpoint(String endpoint) { * @return the git hub builder */ public GitHubBuilder withPassword(String user, String password) { - this.user = user; - this.password = password; - return this; + return withAuthorizationProvider(ImmutableAuthorizationProvider.fromLoginAndPassword(user, password)); } /** @@ -260,7 +268,7 @@ public GitHubBuilder withPassword(String user, String password) { * @return the git hub builder */ public GitHubBuilder withOAuthToken(String oauthToken) { - return withOAuthToken(oauthToken, null); + return withAuthorizationProvider(ImmutableAuthorizationProvider.fromOauthToken(oauthToken)); } /** @@ -273,8 +281,21 @@ public GitHubBuilder withOAuthToken(String oauthToken) { * @return the git hub builder */ public GitHubBuilder withOAuthToken(String oauthToken, String user) { - this.oauthToken = oauthToken; - this.user = user; + return withAuthorizationProvider(ImmutableAuthorizationProvider.fromOauthToken(oauthToken, user)); + } + + /** + * Configures a {@link AuthorizationProvider} for this builder + * + * There can be only one authorization provider per client instance. + * + * @param authorizationProvider + * the authorization provider + * @return the git hub builder + * + */ + public GitHubBuilder withAuthorizationProvider(final AuthorizationProvider authorizationProvider) { + this.authorizationProvider = authorizationProvider; return this; } @@ -287,7 +308,7 @@ public GitHubBuilder withOAuthToken(String oauthToken, String user) { * @see GHAppInstallation#createToken(java.util.Map) GHAppInstallation#createToken(java.util.Map) */ public GitHubBuilder withAppInstallationToken(String appInstallationToken) { - return withOAuthToken(appInstallationToken, ""); + return withAuthorizationProvider(ImmutableAuthorizationProvider.fromAppInstallationToken(appInstallationToken)); } /** @@ -298,8 +319,7 @@ public GitHubBuilder withAppInstallationToken(String appInstallationToken) { * @return the git hub builder */ public GitHubBuilder withJwtToken(String jwtToken) { - this.jwtToken = jwtToken; - return this; + return withAuthorizationProvider(ImmutableAuthorizationProvider.fromJwtToken(jwtToken)); } /** @@ -421,14 +441,11 @@ public GitHubBuilder withProxy(final Proxy p) { */ public GitHub build() throws IOException { return new GitHub(endpoint, - user, - oauthToken, - jwtToken, - password, connector, rateLimitHandler, abuseLimitHandler, - rateLimitChecker); + rateLimitChecker, + authorizationProvider); } @Override diff --git a/src/main/java/org/kohsuke/github/GitHubClient.java b/src/main/java/org/kohsuke/github/GitHubClient.java index e59ae5f087..9ba5b91026 100644 --- a/src/main/java/org/kohsuke/github/GitHubClient.java +++ b/src/main/java/org/kohsuke/github/GitHubClient.java @@ -1,33 +1,19 @@ package org.kohsuke.github; -import com.fasterxml.jackson.databind.DeserializationFeature; -import com.fasterxml.jackson.databind.InjectableValues; -import com.fasterxml.jackson.databind.MapperFeature; -import com.fasterxml.jackson.databind.ObjectMapper; -import com.fasterxml.jackson.databind.ObjectReader; -import com.fasterxml.jackson.databind.ObjectWriter; -import com.fasterxml.jackson.databind.PropertyNamingStrategy; +import com.fasterxml.jackson.databind.*; import com.fasterxml.jackson.databind.introspect.VisibilityChecker; import org.apache.commons.io.IOUtils; +import org.kohsuke.github.authorization.AuthorizationProvider; +import org.kohsuke.github.authorization.UserAuthorizationProvider; import java.io.FileNotFoundException; import java.io.IOException; import java.io.InterruptedIOException; -import java.net.HttpURLConnection; -import java.net.MalformedURLException; -import java.net.SocketException; -import java.net.SocketTimeoutException; -import java.net.URL; -import java.nio.charset.StandardCharsets; +import java.net.*; import java.time.Instant; import java.time.format.DateTimeFormatter; import java.time.temporal.ChronoUnit; -import java.util.Base64; -import java.util.Date; -import java.util.HashMap; -import java.util.List; -import java.util.Map; -import java.util.Objects; +import java.util.*; import java.util.function.Consumer; import java.util.logging.Logger; @@ -56,17 +42,13 @@ abstract class GitHubClient { static final int retryTimeoutMillis = 100; /* private */ final String login; - /** - * Value of the authorization header to be sent with the request. - */ - /* private */ final String encodedAuthorization; - // Cache of myself object. private final String apiUrl; protected final RateLimitHandler rateLimitHandler; protected final AbuseLimitHandler abuseLimitHandler; private final GitHubRateLimitChecker rateLimitChecker; + private final AuthorizationProvider authorizationProvider; private HttpConnector connector; @@ -91,15 +73,12 @@ abstract class GitHubClient { } GitHubClient(String apiUrl, - String login, - String oauthAccessToken, - String jwtToken, - String password, HttpConnector connector, RateLimitHandler rateLimitHandler, AbuseLimitHandler abuseLimitHandler, GitHubRateLimitChecker rateLimitChecker, - Consumer myselfConsumer) throws IOException { + Consumer myselfConsumer, + AuthorizationProvider authorizationProvider) throws IOException { if (apiUrl.endsWith("/")) { apiUrl = apiUrl.substring(0, apiUrl.length() - 1); // normalize @@ -111,33 +90,38 @@ abstract class GitHubClient { this.apiUrl = apiUrl; this.connector = connector; - if (oauthAccessToken != null) { - encodedAuthorization = "token " + oauthAccessToken; - } else { - if (jwtToken != null) { - encodedAuthorization = "Bearer " + jwtToken; - } else if (password != null) { - String authorization = (login + ':' + password); - String charsetName = StandardCharsets.UTF_8.name(); - encodedAuthorization = "Basic " - + Base64.getEncoder().encodeToString(authorization.getBytes(charsetName)); - } else {// anonymous access - encodedAuthorization = null; - } - } + // Prefer credential configuration via provider + this.authorizationProvider = authorizationProvider; this.rateLimitHandler = rateLimitHandler; this.abuseLimitHandler = abuseLimitHandler; this.rateLimitChecker = rateLimitChecker; - if (login == null && encodedAuthorization != null && jwtToken == null) { - GHMyself myself = fetch(GHMyself.class, "/user"); - login = myself.getLogin(); - if (myselfConsumer != null) { - myselfConsumer.accept(myself); + this.login = getCurrentUser(myselfConsumer); + } + + private String getCurrentUser(Consumer myselfConsumer) throws IOException { + String login = null; + if (this.authorizationProvider instanceof UserAuthorizationProvider + && this.authorizationProvider.getEncodedAuthorization() != null) { + + UserAuthorizationProvider userAuthorizationProvider = (UserAuthorizationProvider) this.authorizationProvider; + + login = userAuthorizationProvider.getLogin(); + + if (login == null) { + try { + GHMyself myself = fetch(GHMyself.class, "/user"); + if (myselfConsumer != null) { + myselfConsumer.accept(myself); + } + login = myself.getLogin(); + } catch (IOException e) { + return null; + } } } - this.login = login; + return login; } private T fetch(Class type, String urlPath) throws IOException { @@ -202,7 +186,13 @@ public void setConnector(HttpConnector connector) { * @return {@code true} if operations that require authentication will fail. */ public boolean isAnonymous() { - return login == null && encodedAuthorization == null; + try { + return login == null && this.authorizationProvider.getEncodedAuthorization() == null; + } catch (IOException e) { + // An exception here means that the provider failed to provide authorization parameters, + // basically meaning the same as "no auth" + return false; + } } /** @@ -224,6 +214,11 @@ public GHRateLimit getRateLimit() throws IOException { return getRateLimit(RateLimitTarget.NONE); } + @CheckForNull + protected String getEncodedAuthorization() throws IOException { + return authorizationProvider.getEncodedAuthorization(); + } + @Nonnull GHRateLimit getRateLimit(@Nonnull RateLimitTarget rateLimitTarget) throws IOException { GHRateLimit result; @@ -394,7 +389,6 @@ public GitHubResponse sendRequest(GitHubRequest request, @CheckForNull Gi "GitHub API request [" + (login == null ? "anonymous" : login) + "]: " + request.method() + " " + request.url().toString()); } - rateLimitChecker.checkRateLimit(this, request); responseInfo = getResponseInfo(request); diff --git a/src/main/java/org/kohsuke/github/GitHubHttpUrlConnectionClient.java b/src/main/java/org/kohsuke/github/GitHubHttpUrlConnectionClient.java index dcc56529a8..0fcfc65769 100644 --- a/src/main/java/org/kohsuke/github/GitHubHttpUrlConnectionClient.java +++ b/src/main/java/org/kohsuke/github/GitHubHttpUrlConnectionClient.java @@ -1,6 +1,7 @@ package org.kohsuke.github; import org.apache.commons.io.IOUtils; +import org.kohsuke.github.authorization.AuthorizationProvider; import java.io.IOException; import java.io.InputStream; @@ -33,25 +34,19 @@ class GitHubHttpUrlConnectionClient extends GitHubClient { GitHubHttpUrlConnectionClient(String apiUrl, - String login, - String oauthAccessToken, - String jwtToken, - String password, HttpConnector connector, RateLimitHandler rateLimitHandler, AbuseLimitHandler abuseLimitHandler, GitHubRateLimitChecker rateLimitChecker, - Consumer myselfConsumer) throws IOException { + Consumer myselfConsumer, + AuthorizationProvider authorizationProvider) throws IOException { super(apiUrl, - login, - oauthAccessToken, - jwtToken, - password, connector, rateLimitHandler, abuseLimitHandler, rateLimitChecker, - myselfConsumer); + myselfConsumer, + authorizationProvider); } @Nonnull @@ -114,8 +109,12 @@ static HttpURLConnection setupConnection(@Nonnull GitHubClient client, @Nonnull // if the authentication is needed but no credential is given, try it anyway (so that some calls // that do work with anonymous access in the reduced form should still work.) - if (client.encodedAuthorization != null) - connection.setRequestProperty("Authorization", client.encodedAuthorization); + if (!request.headers().containsKey("Authorization")) { + String authorization = client.getEncodedAuthorization(); + if (authorization != null) { + connection.setRequestProperty("Authorization", client.getEncodedAuthorization()); + } + } setRequestMethod(request.method(), connection); buildRequest(request, connection); diff --git a/src/main/java/org/kohsuke/github/authorization/AuthorizationProvider.java b/src/main/java/org/kohsuke/github/authorization/AuthorizationProvider.java new file mode 100644 index 0000000000..4dd615885d --- /dev/null +++ b/src/main/java/org/kohsuke/github/authorization/AuthorizationProvider.java @@ -0,0 +1,43 @@ +package org.kohsuke.github.authorization; + +import java.io.IOException; + +/** + * Provides a functional interface that returns a valid encodedAuthorization. This strategy allows for a provider that + * dynamically changes the credentials. Each request will request the credentials from the provider. + */ +public interface AuthorizationProvider { + /** + * An static instance for an ANONYMOUS authorization provider + */ + AuthorizationProvider ANONYMOUS = new AnonymousAuthorizationProvider(); + + /** + * Returns the credentials to be used with a given request. As an example, a authorization provider for a bearer + * token will return something like: + * + *
+     * {@code
+     *  @Override
+     *  public String getEncodedAuthorization() {
+     *  return "Bearer myBearerToken";
+     *  }
+     * }
+     * 
+ * + * @return encoded authorization string, can be null + * @throws IOException + * on any error that prevents the provider from getting a valid authorization + */ + String getEncodedAuthorization() throws IOException; + + /** + * A {@link AuthorizationProvider} that ensures that no credentials are returned + */ + class AnonymousAuthorizationProvider implements AuthorizationProvider { + @Override + public String getEncodedAuthorization() throws IOException { + return null; + } + } +} diff --git a/src/main/java/org/kohsuke/github/authorization/ImmutableAuthorizationProvider.java b/src/main/java/org/kohsuke/github/authorization/ImmutableAuthorizationProvider.java new file mode 100644 index 0000000000..41a113285a --- /dev/null +++ b/src/main/java/org/kohsuke/github/authorization/ImmutableAuthorizationProvider.java @@ -0,0 +1,123 @@ +package org.kohsuke.github.authorization; + +import java.io.UnsupportedEncodingException; +import java.nio.charset.StandardCharsets; +import java.util.Base64; + +import javax.annotation.CheckForNull; + +/** + * A {@link AuthorizationProvider} that always returns the same credentials + */ +public class ImmutableAuthorizationProvider implements AuthorizationProvider { + + private final String authorization; + + public ImmutableAuthorizationProvider(String authorization) { + this.authorization = authorization; + } + + /** + * Builds and returns a {@link AuthorizationProvider} from a given oauthAccessToken + * + * @param oauthAccessToken + * The token + * @return a correctly configured {@link AuthorizationProvider} that will always return the same provided + * oauthAccessToken + */ + public static AuthorizationProvider fromOauthToken(String oauthAccessToken) { + return new UserProvider(String.format("token %s", oauthAccessToken)); + } + + /** + * Builds and returns a {@link AuthorizationProvider} from a given oauthAccessToken + * + * @param oauthAccessToken + * The token + * @param login + * The login for this token + * + * @return a correctly configured {@link AuthorizationProvider} that will always return the same provided + * oauthAccessToken + */ + public static AuthorizationProvider fromOauthToken(String oauthAccessToken, String login) { + return new UserProvider(String.format("token %s", oauthAccessToken), login); + } + + /** + * Builds and returns a {@link AuthorizationProvider} from a given App Installation Token + * + * @param appInstallationToken + * A string containing the GitHub App installation token + * @return the configured Builder from given GitHub App installation token. + */ + public static AuthorizationProvider fromAppInstallationToken(String appInstallationToken) { + return fromOauthToken(appInstallationToken, ""); + } + + /** + * Builds and returns a {@link AuthorizationProvider} from a given jwtToken + * + * @param jwtToken + * The JWT token + * @return a correctly configured {@link AuthorizationProvider} that will always return the same provided jwtToken + */ + public static AuthorizationProvider fromJwtToken(String jwtToken) { + return new ImmutableAuthorizationProvider(String.format("Bearer %s", jwtToken)); + } + + /** + * Builds and returns a {@link AuthorizationProvider} from the given user/password pair + * + * @param login + * The login for the user, usually the same as the username + * @param password + * The password for the associated user + * @return a correctly configured {@link AuthorizationProvider} that will always return the credentials for the same + * user and password combo + * @deprecated Login with password credentials are no longer supported by GitHub + */ + @Deprecated + public static AuthorizationProvider fromLoginAndPassword(String login, String password) { + try { + String authorization = (String.format("%s:%s", login, password)); + String charsetName = StandardCharsets.UTF_8.name(); + String b64encoded = Base64.getEncoder().encodeToString(authorization.getBytes(charsetName)); + String encodedAuthorization = String.format("Basic %s", b64encoded); + return new UserProvider(encodedAuthorization, login); + } catch (UnsupportedEncodingException e) { + // If UTF-8 isn't supported, there are bigger problems + throw new IllegalStateException("Could not generate encoded authorization", e); + } + } + + @Override + public String getEncodedAuthorization() { + return this.authorization; + } + + /** + * An internal class representing all user-related credentials, which are credentials that have a login or should + * query the user endpoint for the login matching this credential. + */ + private static class UserProvider extends ImmutableAuthorizationProvider implements UserAuthorizationProvider { + + private final String login; + + UserProvider(String authorization) { + this(authorization, null); + } + + UserProvider(String authorization, String login) { + super(authorization); + this.login = login; + } + + @CheckForNull + @Override + public String getLogin() { + return login; + } + + } +} diff --git a/src/main/java/org/kohsuke/github/authorization/OrgAppInstallationAuthorizationProvider.java b/src/main/java/org/kohsuke/github/authorization/OrgAppInstallationAuthorizationProvider.java new file mode 100644 index 0000000000..020725fb41 --- /dev/null +++ b/src/main/java/org/kohsuke/github/authorization/OrgAppInstallationAuthorizationProvider.java @@ -0,0 +1,63 @@ +package org.kohsuke.github.authorization; + +import org.kohsuke.github.BetaApi; +import org.kohsuke.github.GHAppInstallation; +import org.kohsuke.github.GHAppInstallationToken; +import org.kohsuke.github.GitHub; + +import java.io.IOException; +import java.time.Duration; +import java.time.Instant; +import java.util.Objects; + +import javax.annotation.Nonnull; + +/** + * Provides an AuthorizationProvider that performs automatic token refresh. + */ +public class OrgAppInstallationAuthorizationProvider extends GitHub.DependentAuthorizationProvider { + + private final String organizationName; + + private String latestToken; + + @Nonnull + private Instant validUntil = Instant.MIN; + + /** + * Provides an AuthorizationProvider that performs automatic token refresh, based on an previously authenticated + * github client. + * + * @param organizationName + * The name of the organization where the application is installed + * @param authorizationProvider + * A authorization provider that returns a JWT token that can be used to refresh the App Installation + * token from GitHub. + */ + @BetaApi + @Deprecated + public OrgAppInstallationAuthorizationProvider(String organizationName, + AuthorizationProvider authorizationProvider) { + super(authorizationProvider); + this.organizationName = organizationName; + } + + @Override + public String getEncodedAuthorization() throws IOException { + synchronized (this) { + if (latestToken == null || Instant.now().isAfter(this.validUntil)) { + refreshToken(); + } + return String.format("token %s", latestToken); + } + } + + private void refreshToken() throws IOException { + GitHub gitHub = this.gitHub(); + GHAppInstallation installationByOrganization = gitHub.getApp() + .getInstallationByOrganization(this.organizationName); + GHAppInstallationToken ghAppInstallationToken = installationByOrganization.createToken().create(); + this.validUntil = ghAppInstallationToken.getExpiresAt().toInstant().minus(Duration.ofMinutes(5)); + this.latestToken = Objects.requireNonNull(ghAppInstallationToken.getToken()); + } +} diff --git a/src/main/java/org/kohsuke/github/authorization/UserAuthorizationProvider.java b/src/main/java/org/kohsuke/github/authorization/UserAuthorizationProvider.java new file mode 100644 index 0000000000..0d2c57751b --- /dev/null +++ b/src/main/java/org/kohsuke/github/authorization/UserAuthorizationProvider.java @@ -0,0 +1,22 @@ +package org.kohsuke.github.authorization; + +import javax.annotation.CheckForNull; + +/** + * Interface for all user-related authorization providers. + * + * {@link AuthorizationProvider}s can apply to a number of different account types. This interface applies to providers + * for user accounts, ones that have a login or should query the "/user" endpoint for the login matching this + * credential. + */ +public interface UserAuthorizationProvider extends AuthorizationProvider { + + /** + * Gets the user login name. + * + * @return the user login for this provider, or {@code null} if the login value should be queried from the "/user" + * endpoint. + */ + @CheckForNull + String getLogin(); +} diff --git a/src/main/java/org/kohsuke/github/extras/authorization/JWTTokenProvider.java b/src/main/java/org/kohsuke/github/extras/authorization/JWTTokenProvider.java new file mode 100644 index 0000000000..c8e8d7ddb6 --- /dev/null +++ b/src/main/java/org/kohsuke/github/extras/authorization/JWTTokenProvider.java @@ -0,0 +1,130 @@ +package org.kohsuke.github.extras.authorization; + +import io.jsonwebtoken.JwtBuilder; +import io.jsonwebtoken.Jwts; +import io.jsonwebtoken.SignatureAlgorithm; +import org.kohsuke.github.authorization.AuthorizationProvider; + +import java.io.File; +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.security.GeneralSecurityException; +import java.security.KeyFactory; +import java.security.PrivateKey; +import java.security.spec.InvalidKeySpecException; +import java.security.spec.PKCS8EncodedKeySpec; +import java.time.Duration; +import java.time.Instant; +import java.util.Base64; +import java.util.Date; + +import javax.annotation.Nonnull; + +/** + * A authorization provider that gives valid JWT tokens. These tokens are then used to create a time-based token to + * authenticate as an application. This token provider does not provide any kind of caching, and will always request a + * new token to the API. + */ +public class JWTTokenProvider implements AuthorizationProvider { + + private final PrivateKey privateKey; + + @Nonnull + private Instant validUntil = Instant.MIN; + + private String token; + + /** + * The identifier for the application + */ + private final String applicationId; + + public JWTTokenProvider(String applicationId, File keyFile) throws GeneralSecurityException, IOException { + this(applicationId, loadPrivateKey(keyFile.toPath())); + } + + public JWTTokenProvider(String applicationId, Path keyPath) throws GeneralSecurityException, IOException { + this(applicationId, loadPrivateKey(keyPath)); + } + + public JWTTokenProvider(String applicationId, PrivateKey privateKey) { + this.privateKey = privateKey; + this.applicationId = applicationId; + } + + @Override + public String getEncodedAuthorization() throws IOException { + synchronized (this) { + if (Instant.now().isAfter(validUntil)) { + token = refreshJWT(); + } + return String.format("Bearer %s", token); + } + } + + /** + * add dependencies for a jwt suite You can generate a key to load in this method with: + * + *
+     * openssl pkcs8 -topk8 -inform PEM -outform DER -in ~/github-api-app.private-key.pem -out ~/github-api-app.private-key.der -nocrypt
+     * 
+ */ + private static PrivateKey loadPrivateKey(Path keyPath) throws GeneralSecurityException, IOException { + String keyString = new String(Files.readAllBytes(keyPath), StandardCharsets.UTF_8); + return getPrivateKeyFromString(keyString); + } + + /** + * 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 InvalidKeySpecException( + "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"); + } + + // Remove all comments and whitespace from PEM + // such as "-----BEGIN PRIVATE KEY-----" and newlines + String privateKeyContent = key.replaceAll("(?m)^--.*", "").replaceAll("\\s", ""); + + 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 InvalidKeySpecException("Failed to decode private key: " + e.getMessage(), e); + } + } + + private String refreshJWT() { + Instant now = Instant.now(); + + // Token expires in 10 minutes + Instant expiration = Instant.now().plus(Duration.ofMinutes(10)); + + // Let's set the JWT Claims + JwtBuilder builder = Jwts.builder() + .setIssuedAt(Date.from(now)) + .setExpiration(Date.from(expiration)) + .setIssuer(this.applicationId) + .signWith(privateKey, SignatureAlgorithm.RS256); + + // Token will refresh after 8 minutes + validUntil = expiration.minus(Duration.ofMinutes(2)); + + // Builds the JWT and serializes it to a compact, URL-safe string + return builder.compact(); + } +} diff --git a/src/test/java/org/kohsuke/github/AbstractGHAppInstallationTest.java b/src/test/java/org/kohsuke/github/AbstractGHAppInstallationTest.java index b25d13bc55..74576ba58c 100644 --- a/src/test/java/org/kohsuke/github/AbstractGHAppInstallationTest.java +++ b/src/test/java/org/kohsuke/github/AbstractGHAppInstallationTest.java @@ -2,8 +2,12 @@ import io.jsonwebtoken.Jwts; import org.apache.commons.io.IOUtils; +import org.kohsuke.github.authorization.AuthorizationProvider; +import org.kohsuke.github.extras.authorization.JWTTokenProvider; +import java.io.File; import java.io.IOException; +import java.security.GeneralSecurityException; import java.security.KeyFactory; import java.security.PrivateKey; import java.security.spec.PKCS8EncodedKeySpec; @@ -21,6 +25,23 @@ public class AbstractGHAppInstallationTest extends AbstractGitHubWireMockTest { private static String PRIVATE_KEY_FILE_APP_2 = "/ghapi-test-app-2.private-key.pem"; private static String PRIVATE_KEY_FILE_APP_3 = "/ghapi-test-app-3.private-key.pem"; + private static AuthorizationProvider JWT_PROVIDER_1; + private static AuthorizationProvider JWT_PROVIDER_2; + private static AuthorizationProvider JWT_PROVIDER_3; + + AbstractGHAppInstallationTest() { + try { + JWT_PROVIDER_1 = new JWTTokenProvider(TEST_APP_ID_1, + new File(this.getClass().getResource(PRIVATE_KEY_FILE_APP_1).getFile())); + JWT_PROVIDER_2 = new JWTTokenProvider(TEST_APP_ID_2, + new File(this.getClass().getResource(PRIVATE_KEY_FILE_APP_2).getFile())); + JWT_PROVIDER_3 = new JWTTokenProvider(TEST_APP_ID_3, + new File(this.getClass().getResource(PRIVATE_KEY_FILE_APP_3).getFile())); + } catch (GeneralSecurityException | IOException e) { + throw new RuntimeException("These should never fail", e); + } + } + private String createJwtToken(String keyFileResouceName, String appId) { try { String keyPEM = IOUtils.toString(this.getClass().getResource(keyFileResouceName), "US-ASCII") @@ -63,15 +84,15 @@ private GHAppInstallation getAppInstallationWithToken(String jwtToken) throws IO } protected GHAppInstallation getAppInstallationWithTokenApp1() throws IOException { - return getAppInstallationWithToken(createJwtToken(PRIVATE_KEY_FILE_APP_1, TEST_APP_ID_1)); + return getAppInstallationWithToken(JWT_PROVIDER_1.getEncodedAuthorization()); } protected GHAppInstallation getAppInstallationWithTokenApp2() throws IOException { - return getAppInstallationWithToken(createJwtToken(PRIVATE_KEY_FILE_APP_2, TEST_APP_ID_2)); + return getAppInstallationWithToken(JWT_PROVIDER_2.getEncodedAuthorization()); } protected GHAppInstallation getAppInstallationWithTokenApp3() throws IOException { - return getAppInstallationWithToken(createJwtToken(PRIVATE_KEY_FILE_APP_3, TEST_APP_ID_3)); + return getAppInstallationWithToken(JWT_PROVIDER_3.getEncodedAuthorization()); } } diff --git a/src/test/java/org/kohsuke/github/AbstractGitHubWireMockTest.java b/src/test/java/org/kohsuke/github/AbstractGitHubWireMockTest.java index 8cede300c7..84b838ac41 100644 --- a/src/test/java/org/kohsuke/github/AbstractGitHubWireMockTest.java +++ b/src/test/java/org/kohsuke/github/AbstractGitHubWireMockTest.java @@ -100,7 +100,6 @@ protected GitHubBuilder getGitHubBuilder() { // This sets the user and password to a placeholder for wiremock testing // This makes the tests believe they are running with permissions // The recorded stubs will behave like they running with permissions - builder.oauthToken = null; builder.withPassword(STUBBED_USER_LOGIN, STUBBED_USER_PASSWORD); } diff --git a/src/test/java/org/kohsuke/github/GitHubConnectionTest.java b/src/test/java/org/kohsuke/github/GitHubConnectionTest.java index 882cc3f4b7..0fb168eae8 100644 --- a/src/test/java/org/kohsuke/github/GitHubConnectionTest.java +++ b/src/test/java/org/kohsuke/github/GitHubConnectionTest.java @@ -1,11 +1,14 @@ package org.kohsuke.github; import org.junit.Test; +import org.kohsuke.github.authorization.UserAuthorizationProvider; import java.io.IOException; import java.lang.reflect.Field; import java.util.*; +import static org.hamcrest.CoreMatchers.*; + /** * Unit test for {@link GitHub}. */ @@ -56,19 +59,40 @@ public void testGitHubBuilderFromEnvironment() throws IOException { Map props = new HashMap(); - props.put("login", "bogus"); - props.put("oauth", "bogus"); - props.put("password", "bogus"); - props.put("jwt", "bogus"); + props.put("endpoint", "bogus endpoint url"); + props.put("oauth", "bogus oauth token string"); + setupEnvironment(props); + GitHubBuilder builder = GitHubBuilder.fromEnvironment(); + + assertThat(builder.endpoint, equalTo("bogus endpoint url")); + + assertThat(builder.authorizationProvider, instanceOf(UserAuthorizationProvider.class)); + assertThat(builder.authorizationProvider.getEncodedAuthorization(), equalTo("token bogus oauth token string")); + assertThat(((UserAuthorizationProvider) builder.authorizationProvider).getLogin(), nullValue()); + props.put("login", "bogus login"); setupEnvironment(props); + builder = GitHubBuilder.fromEnvironment(); - GitHubBuilder builder = GitHubBuilder.fromEnvironment(); + assertThat(builder.authorizationProvider, instanceOf(UserAuthorizationProvider.class)); + assertThat(builder.authorizationProvider.getEncodedAuthorization(), equalTo("token bogus oauth token string")); + assertThat(((UserAuthorizationProvider) builder.authorizationProvider).getLogin(), equalTo("bogus login")); + + props.put("jwt", "bogus jwt token string"); + setupEnvironment(props); + builder = GitHubBuilder.fromEnvironment(); + + assertThat(builder.authorizationProvider, not(instanceOf(UserAuthorizationProvider.class))); + assertThat(builder.authorizationProvider.getEncodedAuthorization(), equalTo("Bearer bogus jwt token string")); + + props.put("password", "bogus weak password"); + setupEnvironment(props); + builder = GitHubBuilder.fromEnvironment(); - assertEquals("bogus", builder.user); - assertEquals("bogus", builder.oauthToken); - assertEquals("bogus", builder.password); - assertEquals("bogus", builder.jwtToken); + assertThat(builder.authorizationProvider, instanceOf(UserAuthorizationProvider.class)); + assertThat(builder.authorizationProvider.getEncodedAuthorization(), + equalTo("Basic Ym9ndXMgbG9naW46Ym9ndXMgd2VhayBwYXNzd29yZA==")); + assertThat(((UserAuthorizationProvider) builder.authorizationProvider).getLogin(), equalTo("bogus login")); } @@ -76,32 +100,48 @@ public void testGitHubBuilderFromEnvironment() throws IOException { public void testGitHubBuilderFromCustomEnvironment() throws IOException { Map props = new HashMap(); - props.put("customLogin", "bogusLogin"); - props.put("customOauth", "bogusOauth"); - props.put("customPassword", "bogusPassword"); - props.put("customEndpoint", "bogusEndpoint"); - + props.put("customEndpoint", "bogus endpoint url"); + props.put("customOauth", "bogus oauth token string"); setupEnvironment(props); - GitHubBuilder builder = GitHubBuilder .fromEnvironment("customLogin", "customPassword", "customOauth", "customEndpoint"); - assertEquals("bogusLogin", builder.user); - assertEquals("bogusOauth", builder.oauthToken); - assertEquals("bogusPassword", builder.password); - assertEquals("bogusEndpoint", builder.endpoint); + assertThat(builder.endpoint, equalTo("bogus endpoint url")); + + assertThat(builder.authorizationProvider, instanceOf(UserAuthorizationProvider.class)); + assertThat(builder.authorizationProvider.getEncodedAuthorization(), equalTo("token bogus oauth token string")); + assertThat(((UserAuthorizationProvider) builder.authorizationProvider).getLogin(), nullValue()); + + props.put("customLogin", "bogus login"); + setupEnvironment(props); + builder = GitHubBuilder.fromEnvironment("customLogin", "customPassword", "customOauth", "customEndpoint"); + + assertThat(builder.authorizationProvider, instanceOf(UserAuthorizationProvider.class)); + assertThat(builder.authorizationProvider.getEncodedAuthorization(), equalTo("token bogus oauth token string")); + assertThat(((UserAuthorizationProvider) builder.authorizationProvider).getLogin(), equalTo("bogus login")); + + props.put("customPassword", "bogus weak password"); + setupEnvironment(props); + builder = GitHubBuilder.fromEnvironment("customLogin", "customPassword", "customOauth", "customEndpoint"); + + assertThat(builder.authorizationProvider, instanceOf(UserAuthorizationProvider.class)); + assertThat(builder.authorizationProvider.getEncodedAuthorization(), + equalTo("Basic Ym9ndXMgbG9naW46Ym9ndXMgd2VhayBwYXNzd29yZA==")); + assertThat(((UserAuthorizationProvider) builder.authorizationProvider).getLogin(), equalTo("bogus login")); } @Test public void testGithubBuilderWithAppInstallationToken() throws Exception { - GitHubBuilder builder = new GitHubBuilder().withAppInstallationToken("bogus"); - assertEquals("bogus", builder.oauthToken); - assertEquals("", builder.user); + + GitHubBuilder builder = new GitHubBuilder().withAppInstallationToken("bogus app token"); + assertThat(builder.authorizationProvider, instanceOf(UserAuthorizationProvider.class)); + assertThat(builder.authorizationProvider.getEncodedAuthorization(), equalTo("token bogus app token")); + assertThat(((UserAuthorizationProvider) builder.authorizationProvider).getLogin(), equalTo("")); // test authorization header is set as in the RFC6749 GitHub github = builder.build(); // change this to get a request - assertEquals("token bogus", github.getClient().encodedAuthorization); + assertEquals("token bogus app token", github.getClient().getEncodedAuthorization()); assertEquals("", github.getClient().login); } diff --git a/src/test/java/org/kohsuke/github/OrgAppInstallationAuthorizationProviderTest.java b/src/test/java/org/kohsuke/github/OrgAppInstallationAuthorizationProviderTest.java new file mode 100644 index 0000000000..987e9a64a2 --- /dev/null +++ b/src/test/java/org/kohsuke/github/OrgAppInstallationAuthorizationProviderTest.java @@ -0,0 +1,43 @@ +package org.kohsuke.github; + +import org.junit.Test; +import org.kohsuke.github.authorization.ImmutableAuthorizationProvider; +import org.kohsuke.github.authorization.OrgAppInstallationAuthorizationProvider; + +import java.io.IOException; + +import static org.hamcrest.CoreMatchers.equalTo; +import static org.hamcrest.CoreMatchers.notNullValue; + +public class OrgAppInstallationAuthorizationProviderTest extends AbstractGHAppInstallationTest { + + public OrgAppInstallationAuthorizationProviderTest() { + useDefaultGitHub = false; + } + + @Test(expected = HttpException.class) + public void invalidJWTTokenRaisesException() throws IOException { + OrgAppInstallationAuthorizationProvider provider = new OrgAppInstallationAuthorizationProvider( + "testOrganization", + ImmutableAuthorizationProvider.fromJwtToken("myToken")); + gitHub = getGitHubBuilder().withAuthorizationProvider(provider) + .withEndpoint(mockGitHub.apiServer().baseUrl()) + .build(); + + provider.getEncodedAuthorization(); + } + + @Test + public void validJWTTokenAllowsOauthTokenRequest() throws IOException { + OrgAppInstallationAuthorizationProvider provider = new OrgAppInstallationAuthorizationProvider("hub4j-test-org", + ImmutableAuthorizationProvider.fromJwtToken("bogus-valid-token")); + gitHub = getGitHubBuilder().withAuthorizationProvider(provider) + .withEndpoint(mockGitHub.apiServer().baseUrl()) + .build(); + String encodedAuthorization = provider.getEncodedAuthorization(); + + assertThat(encodedAuthorization, notNullValue()); + assertThat(encodedAuthorization, equalTo("token v1.9a12d913f980a45a16ac9c3a9d34d9b7sa314cb6")); + } + +} diff --git a/src/test/java/org/kohsuke/github/extras/authorization/JWTTokenProviderTest.java b/src/test/java/org/kohsuke/github/extras/authorization/JWTTokenProviderTest.java new file mode 100644 index 0000000000..8c55558f96 --- /dev/null +++ b/src/test/java/org/kohsuke/github/extras/authorization/JWTTokenProviderTest.java @@ -0,0 +1,47 @@ +package org.kohsuke.github.extras.authorization; + +import org.junit.Test; +import org.kohsuke.github.AbstractGitHubWireMockTest; +import org.kohsuke.github.GitHub; + +import java.io.File; +import java.io.IOException; +import java.security.GeneralSecurityException; +/* + * This test will request an application ensuring that the header for the "Authorization" matches a valid JWT token. + * A JWT token in the Authorization header will always start with "ey" which is always the start of the base64 + * encoding of the JWT Header , so a valid header will look like this: + * + *
+ * Authorization: Bearer ey{rest of the header}.{payload}.{signature}
+ * 
+ * + * Matched by the regular expression: + * + *
+ * ^Bearer (?ey\S*)\.(?\S*)\.(?\S*)$
+ * 
+ * + * Which is present in the wiremock matcher. Note that we need to use a matcher because the JWT token is encoded + * with a private key and a random nonce, so it will never be the same (under normal conditions). For more + * information on the format of a JWT token, see: https://jwt.io/introduction/ + */ +public class JWTTokenProviderTest extends AbstractGitHubWireMockTest { + + private static String TEST_APP_ID_2 = "83009"; + private static String PRIVATE_KEY_FILE_APP_2 = "/ghapi-test-app-2.private-key.pem"; + + @Test + public void testAuthorizationHeaderPattern() throws GeneralSecurityException, IOException { + JWTTokenProvider jwtTokenProvider = new JWTTokenProvider(TEST_APP_ID_2, + new File(this.getClass().getResource(PRIVATE_KEY_FILE_APP_2).getFile())); + GitHub gh = getGitHubBuilder().withEndpoint(mockGitHub.apiServer().baseUrl()) + .withAuthorizationProvider(jwtTokenProvider) + .build(); + + // Request the application, the wiremock matcher will ensure that the header + // for the authorization is present and has a the format of a valid JWT token + gh.getApp(); + } + +} diff --git a/src/test/resources/org/kohsuke/github/OrgAppInstallationAuthorizationProviderTest/wiremock/invalidJWTTokenRaisesException/mappings/app-2.json b/src/test/resources/org/kohsuke/github/OrgAppInstallationAuthorizationProviderTest/wiremock/invalidJWTTokenRaisesException/mappings/app-2.json new file mode 100644 index 0000000000..5849fd75c7 --- /dev/null +++ b/src/test/resources/org/kohsuke/github/OrgAppInstallationAuthorizationProviderTest/wiremock/invalidJWTTokenRaisesException/mappings/app-2.json @@ -0,0 +1,35 @@ +{ + "id": "960b4085-803f-43aa-a291-ccb6fd003adb", + "name": "app", + "request": { + "url": "/app", + "method": "GET", + "headers": { + "Accept": { + "equalTo": "application/vnd.github.machine-man-preview+json" + } + } + }, + "response": { + "status": 401, + "body": "{\"message\":\"A JSON web token could not be decoded\",\"documentation_url\":\"https://docs.github.com/rest\"}", + "headers": { + "Date": "Tue, 29 Sep 2020 12:35:35 GMT", + "Content-Type": "application/json; charset=utf-8", + "Server": "GitHub.com", + "Status": "401 Unauthorized", + "X-GitHub-Media-Type": "github.v3; param=machine-man-preview; format=json", + "Strict-Transport-Security": "max-age=31536000; includeSubdomains; preload", + "X-Frame-Options": "deny", + "X-Content-Type-Options": "nosniff", + "X-XSS-Protection": "1; mode=block", + "Referrer-Policy": "origin-when-cross-origin, strict-origin-when-cross-origin", + "Content-Security-Policy": "default-src 'none'", + "Vary": "Accept-Encoding, Accept, X-Requested-With", + "X-GitHub-Request-Id": "D236:47C4:1909E17E:1DD010FD:5F732A16" + } + }, + "uuid": "960b4085-803f-43aa-a291-ccb6fd003adb", + "persistent": true, + "insertionIndex": 2 +} \ No newline at end of file diff --git a/src/test/resources/org/kohsuke/github/OrgAppInstallationAuthorizationProviderTest/wiremock/invalidJWTTokenRaisesException/mappings/user-1.json b/src/test/resources/org/kohsuke/github/OrgAppInstallationAuthorizationProviderTest/wiremock/invalidJWTTokenRaisesException/mappings/user-1.json new file mode 100644 index 0000000000..feea2b1f2d --- /dev/null +++ b/src/test/resources/org/kohsuke/github/OrgAppInstallationAuthorizationProviderTest/wiremock/invalidJWTTokenRaisesException/mappings/user-1.json @@ -0,0 +1,39 @@ +{ + "id": "31df960e-9966-4b89-8a99-0d6688accca9", + "name": "user", + "request": { + "url": "/user", + "method": "GET", + "headers": { + "Accept": { + "equalTo": "text/html, image/gif, image/jpeg, *; q=.2, */*; q=.2" + } + } + }, + "response": { + "status": 401, + "body": "{\"message\":\"Bad credentials\",\"documentation_url\":\"https://docs.github.com/rest\"}", + "headers": { + "Date": "Tue, 29 Sep 2020 12:35:34 GMT", + "Content-Type": "application/json; charset=utf-8", + "Server": "GitHub.com", + "Status": "401 Unauthorized", + "X-GitHub-Media-Type": "unknown, github.v3", + "X-RateLimit-Limit": "60", + "X-RateLimit-Remaining": "55", + "X-RateLimit-Reset": "1601386475", + "X-RateLimit-Used": "5", + "Strict-Transport-Security": "max-age=31536000; includeSubdomains; preload", + "X-Frame-Options": "deny", + "X-Content-Type-Options": "nosniff", + "X-XSS-Protection": "1; mode=block", + "Referrer-Policy": "origin-when-cross-origin, strict-origin-when-cross-origin", + "Content-Security-Policy": "default-src 'none'", + "Vary": "Accept-Encoding, Accept, X-Requested-With", + "X-GitHub-Request-Id": "D236:47C4:1909E038:1DD010AD:5F732A16" + } + }, + "uuid": "31df960e-9966-4b89-8a99-0d6688accca9", + "persistent": true, + "insertionIndex": 1 +} \ No newline at end of file diff --git a/src/test/resources/org/kohsuke/github/OrgAppInstallationAuthorizationProviderTest/wiremock/validJWTTokenAllowsOauthTokenRequest/__files/app-2.json b/src/test/resources/org/kohsuke/github/OrgAppInstallationAuthorizationProviderTest/wiremock/validJWTTokenAllowsOauthTokenRequest/__files/app-2.json new file mode 100644 index 0000000000..4442d5c635 --- /dev/null +++ b/src/test/resources/org/kohsuke/github/OrgAppInstallationAuthorizationProviderTest/wiremock/validJWTTokenAllowsOauthTokenRequest/__files/app-2.json @@ -0,0 +1,39 @@ +{ + "id": 79253, + "slug": "hub4j-test-application", + "node_id": "MDM6QXBwNzkyNTM=", + "owner": { + "login": "hub4j-test-org", + "id": 70590530, + "node_id": "MDEyOk9yZ2FuaXphdGlvbjcwNTkwNTMw", + "avatar_url": "https://avatars1.githubusercontent.com/u/70590530?v=4", + "gravatar_id": "", + "url": "https://api.github.com/users/hub4j-test-org", + "html_url": "https://github.com/hub4j-test-org", + "followers_url": "https://api.github.com/users/hub4j-test-org/followers", + "following_url": "https://api.github.com/users/hub4j-test-org/following{/other_user}", + "gists_url": "https://api.github.com/users/hub4j-test-org/gists{/gist_id}", + "starred_url": "https://api.github.com/users/hub4j-test-org/starred{/owner}{/repo}", + "subscriptions_url": "https://api.github.com/users/hub4j-test-org/subscriptions", + "organizations_url": "https://api.github.com/users/hub4j-test-org/orgs", + "repos_url": "https://api.github.com/users/hub4j-test-org/repos", + "events_url": "https://api.github.com/users/hub4j-test-org/events{/privacy}", + "received_events_url": "https://api.github.com/users/hub4j-test-org/received_events", + "type": "Organization", + "site_admin": false + }, + "name": "hub4j-test-application", + "description": "", + "external_url": "https://example.com", + "html_url": "https://github.com/apps/hub4j-test-application", + "created_at": "2020-09-01T14:56:16Z", + "updated_at": "2020-09-01T14:56:16Z", + "permissions": { + "metadata": "read", + "pull_requests": "write" + }, + "events": [ + "pull_request" + ], + "installations_count": 1 +} \ No newline at end of file diff --git a/src/test/resources/org/kohsuke/github/OrgAppInstallationAuthorizationProviderTest/wiremock/validJWTTokenAllowsOauthTokenRequest/__files/orgs_hub4j-test-org_installation-3.json b/src/test/resources/org/kohsuke/github/OrgAppInstallationAuthorizationProviderTest/wiremock/validJWTTokenAllowsOauthTokenRequest/__files/orgs_hub4j-test-org_installation-3.json new file mode 100644 index 0000000000..80a6c2db36 --- /dev/null +++ b/src/test/resources/org/kohsuke/github/OrgAppInstallationAuthorizationProviderTest/wiremock/validJWTTokenAllowsOauthTokenRequest/__files/orgs_hub4j-test-org_installation-3.json @@ -0,0 +1,43 @@ +{ + "id": 11575015, + "account": { + "login": "hub4j-test-org", + "id": 70590530, + "node_id": "MDEyOk9yZ2FuaXphdGlvbjcwNTkwNTMw", + "avatar_url": "https://avatars1.githubusercontent.com/u/70590530?v=4", + "gravatar_id": "", + "url": "https://api.github.com/users/hub4j-test-org", + "html_url": "https://github.com/hub4j-test-org", + "followers_url": "https://api.github.com/users/hub4j-test-org/followers", + "following_url": "https://api.github.com/users/hub4j-test-org/following{/other_user}", + "gists_url": "https://api.github.com/users/hub4j-test-org/gists{/gist_id}", + "starred_url": "https://api.github.com/users/hub4j-test-org/starred{/owner}{/repo}", + "subscriptions_url": "https://api.github.com/users/hub4j-test-org/subscriptions", + "organizations_url": "https://api.github.com/users/hub4j-test-org/orgs", + "repos_url": "https://api.github.com/users/hub4j-test-org/repos", + "events_url": "https://api.github.com/users/hub4j-test-org/events{/privacy}", + "received_events_url": "https://api.github.com/users/hub4j-test-org/received_events", + "type": "Organization", + "site_admin": false + }, + "repository_selection": "all", + "access_tokens_url": "https://api.github.com/app/installations/11575015/access_tokens", + "repositories_url": "https://api.github.com/installation/repositories", + "html_url": "https://github.com/organizations/hub4j-test-org/settings/installations/11575015", + "app_id": 79253, + "app_slug": "hub4j-test-application", + "target_id": 70590530, + "target_type": "Organization", + "permissions": { + "metadata": "read", + "pull_requests": "write" + }, + "events": [ + "pull_request" + ], + "created_at": "2020-09-01T14:56:49.000Z", + "updated_at": "2020-09-01T14:56:49.000Z", + "single_file_name": null, + "suspended_by": null, + "suspended_at": null +} \ No newline at end of file diff --git a/src/test/resources/org/kohsuke/github/OrgAppInstallationAuthorizationProviderTest/wiremock/validJWTTokenAllowsOauthTokenRequest/mappings/app-2.json b/src/test/resources/org/kohsuke/github/OrgAppInstallationAuthorizationProviderTest/wiremock/validJWTTokenAllowsOauthTokenRequest/mappings/app-2.json new file mode 100644 index 0000000000..86b4f0076e --- /dev/null +++ b/src/test/resources/org/kohsuke/github/OrgAppInstallationAuthorizationProviderTest/wiremock/validJWTTokenAllowsOauthTokenRequest/mappings/app-2.json @@ -0,0 +1,41 @@ +{ + "id": "7b483ea8-ace3-4af3-ae23-b081d717fa53", + "name": "app", + "request": { + "url": "/app", + "method": "GET", + "headers": { + "Accept": { + "equalTo": "application/vnd.github.machine-man-preview+json" + } + } + }, + "response": { + "status": 200, + "bodyFileName": "app-2.json", + "headers": { + "Date": "Tue, 29 Sep 2020 12:35:36 GMT", + "Content-Type": "application/json; charset=utf-8", + "Server": "GitHub.com", + "Status": "200 OK", + "Cache-Control": "public, max-age=60, s-maxage=60", + "Vary": [ + "Accept", + "Accept-Encoding, Accept, X-Requested-With", + "Accept-Encoding" + ], + "ETag": "W/\"a4f1cab410e5b80ee9775d1ecb4d3296f067ddcdfa22ba2122dd382c992b55fe\"", + "X-GitHub-Media-Type": "github.v3; param=machine-man-preview; format=json", + "Strict-Transport-Security": "max-age=31536000; includeSubdomains; preload", + "X-Frame-Options": "deny", + "X-Content-Type-Options": "nosniff", + "X-XSS-Protection": "1; mode=block", + "Referrer-Policy": "origin-when-cross-origin, strict-origin-when-cross-origin", + "Content-Security-Policy": "default-src 'none'", + "X-GitHub-Request-Id": "D11A:F68D:17924B62:1C1232BE:5F732A18" + } + }, + "uuid": "7b483ea8-ace3-4af3-ae23-b081d717fa53", + "persistent": true, + "insertionIndex": 2 +} \ No newline at end of file diff --git a/src/test/resources/org/kohsuke/github/OrgAppInstallationAuthorizationProviderTest/wiremock/validJWTTokenAllowsOauthTokenRequest/mappings/app_installations_11575015_access_tokens-4.json b/src/test/resources/org/kohsuke/github/OrgAppInstallationAuthorizationProviderTest/wiremock/validJWTTokenAllowsOauthTokenRequest/mappings/app_installations_11575015_access_tokens-4.json new file mode 100644 index 0000000000..68600667e9 --- /dev/null +++ b/src/test/resources/org/kohsuke/github/OrgAppInstallationAuthorizationProviderTest/wiremock/validJWTTokenAllowsOauthTokenRequest/mappings/app_installations_11575015_access_tokens-4.json @@ -0,0 +1,48 @@ +{ + "id": "7e25da60-68c9-41c5-b603-359192783583", + "name": "app_installations_11575015_access_tokens", + "request": { + "url": "/app/installations/11575015/access_tokens", + "method": "POST", + "headers": { + "Accept": { + "equalTo": "application/vnd.github.machine-man-preview+json" + } + }, + "bodyPatterns": [ + { + "equalToJson": "{}", + "ignoreArrayOrder": true, + "ignoreExtraElements": false + } + ] + }, + "response": { + "status": 201, + "body": "{\"token\":\"v1.9a12d913f980a45a16ac9c3a9d34d9b7sa314cb6\",\"expires_at\":\"2020-09-29T13:35:37Z\",\"permissions\":{\"metadata\":\"read\",\"pull_requests\":\"write\"},\"repository_selection\":\"all\"}", + "headers": { + "Date": "Tue, 29 Sep 2020 12:35:37 GMT", + "Content-Type": "application/json; charset=utf-8", + "Server": "GitHub.com", + "Status": "201 Created", + "Cache-Control": "public, max-age=60, s-maxage=60", + "Vary": [ + "Accept", + "Accept-Encoding, Accept, X-Requested-With", + "Accept-Encoding" + ], + "ETag": "\"168d81847da026cae71dddc5658dc87c05a2b6945d4e635787c451df823fc72a\"", + "X-GitHub-Media-Type": "github.v3; param=machine-man-preview; format=json", + "Strict-Transport-Security": "max-age=31536000; includeSubdomains; preload", + "X-Frame-Options": "deny", + "X-Content-Type-Options": "nosniff", + "X-XSS-Protection": "1; mode=block", + "Referrer-Policy": "origin-when-cross-origin, strict-origin-when-cross-origin", + "Content-Security-Policy": "default-src 'none'", + "X-GitHub-Request-Id": "D11A:F68D:17924C69:1C12341C:5F732A18" + } + }, + "uuid": "7e25da60-68c9-41c5-b603-359192783583", + "persistent": true, + "insertionIndex": 4 +} \ No newline at end of file diff --git a/src/test/resources/org/kohsuke/github/OrgAppInstallationAuthorizationProviderTest/wiremock/validJWTTokenAllowsOauthTokenRequest/mappings/orgs_hub4j-test-org_installation-3.json b/src/test/resources/org/kohsuke/github/OrgAppInstallationAuthorizationProviderTest/wiremock/validJWTTokenAllowsOauthTokenRequest/mappings/orgs_hub4j-test-org_installation-3.json new file mode 100644 index 0000000000..54e1ea9d4d --- /dev/null +++ b/src/test/resources/org/kohsuke/github/OrgAppInstallationAuthorizationProviderTest/wiremock/validJWTTokenAllowsOauthTokenRequest/mappings/orgs_hub4j-test-org_installation-3.json @@ -0,0 +1,41 @@ +{ + "id": "9ffe1e34-1d0e-495a-abdc-86fdf1d15334", + "name": "orgs_hub4j-test-org_installation", + "request": { + "url": "/orgs/hub4j-test-org/installation", + "method": "GET", + "headers": { + "Accept": { + "equalTo": "application/vnd.github.machine-man-preview+json" + } + } + }, + "response": { + "status": 200, + "bodyFileName": "orgs_hub4j-test-org_installation-3.json", + "headers": { + "Date": "Tue, 29 Sep 2020 12:35:36 GMT", + "Content-Type": "application/json; charset=utf-8", + "Server": "GitHub.com", + "Status": "200 OK", + "Cache-Control": "public, max-age=60, s-maxage=60", + "Vary": [ + "Accept", + "Accept-Encoding, Accept, X-Requested-With", + "Accept-Encoding" + ], + "ETag": "W/\"5fa17d9ba74cf1c58441056ab43311b39f39e78976e8524ad3962278c5224955\"", + "X-GitHub-Media-Type": "github.v3; param=machine-man-preview; format=json", + "Strict-Transport-Security": "max-age=31536000; includeSubdomains; preload", + "X-Frame-Options": "deny", + "X-Content-Type-Options": "nosniff", + "X-XSS-Protection": "1; mode=block", + "Referrer-Policy": "origin-when-cross-origin, strict-origin-when-cross-origin", + "Content-Security-Policy": "default-src 'none'", + "X-GitHub-Request-Id": "D11A:F68D:17924BFB:1C12335A:5F732A18" + } + }, + "uuid": "9ffe1e34-1d0e-495a-abdc-86fdf1d15334", + "persistent": true, + "insertionIndex": 3 +} \ No newline at end of file diff --git a/src/test/resources/org/kohsuke/github/OrgAppInstallationAuthorizationProviderTest/wiremock/validJWTTokenAllowsOauthTokenRequest/mappings/user-1.json b/src/test/resources/org/kohsuke/github/OrgAppInstallationAuthorizationProviderTest/wiremock/validJWTTokenAllowsOauthTokenRequest/mappings/user-1.json new file mode 100644 index 0000000000..ca0c3dee38 --- /dev/null +++ b/src/test/resources/org/kohsuke/github/OrgAppInstallationAuthorizationProviderTest/wiremock/validJWTTokenAllowsOauthTokenRequest/mappings/user-1.json @@ -0,0 +1,39 @@ +{ + "id": "85ae1237-62c3-4f75-888b-8d751677aa07", + "name": "user", + "request": { + "url": "/user", + "method": "GET", + "headers": { + "Accept": { + "equalTo": "text/html, image/gif, image/jpeg, *; q=.2, */*; q=.2" + } + } + }, + "response": { + "status": 401, + "body": "{\"message\":\"Bad credentials\",\"documentation_url\":\"https://docs.github.com/rest\"}", + "headers": { + "Date": "Tue, 29 Sep 2020 12:35:36 GMT", + "Content-Type": "application/json; charset=utf-8", + "Server": "GitHub.com", + "Status": "401 Unauthorized", + "X-GitHub-Media-Type": "unknown, github.v3", + "X-RateLimit-Limit": "60", + "X-RateLimit-Remaining": "53", + "X-RateLimit-Reset": "1601386475", + "X-RateLimit-Used": "7", + "Strict-Transport-Security": "max-age=31536000; includeSubdomains; preload", + "X-Frame-Options": "deny", + "X-Content-Type-Options": "nosniff", + "X-XSS-Protection": "1; mode=block", + "Referrer-Policy": "origin-when-cross-origin, strict-origin-when-cross-origin", + "Content-Security-Policy": "default-src 'none'", + "Vary": "Accept-Encoding, Accept, X-Requested-With", + "X-GitHub-Request-Id": "D11A:F68D:17924B00:1C12327C:5F732A17" + } + }, + "uuid": "85ae1237-62c3-4f75-888b-8d751677aa07", + "persistent": true, + "insertionIndex": 1 +} \ No newline at end of file diff --git a/src/test/resources/org/kohsuke/github/extras/authorization/JWTTokenProviderTest/wiremock/testAuthorizationHeaderPattern/__files/app-1.json b/src/test/resources/org/kohsuke/github/extras/authorization/JWTTokenProviderTest/wiremock/testAuthorizationHeaderPattern/__files/app-1.json new file mode 100644 index 0000000000..8cc884b88a --- /dev/null +++ b/src/test/resources/org/kohsuke/github/extras/authorization/JWTTokenProviderTest/wiremock/testAuthorizationHeaderPattern/__files/app-1.json @@ -0,0 +1,34 @@ +{ + "id": 83009, + "slug": "ghapi-test-app-2", + "node_id": "MDM6QXBwODMwMDk=", + "owner": { + "login": "hub4j-test-org", + "id": 7544739, + "node_id": "MDEyOk9yZ2FuaXphdGlvbjc1NDQ3Mzk=", + "avatar_url": "https://avatars3.githubusercontent.com/u/7544739?v=4", + "gravatar_id": "", + "url": "https://api.github.com/users/hub4j-test-org", + "html_url": "https://github.com/hub4j-test-org", + "followers_url": "https://api.github.com/users/hub4j-test-org/followers", + "following_url": "https://api.github.com/users/hub4j-test-org/following{/other_user}", + "gists_url": "https://api.github.com/users/hub4j-test-org/gists{/gist_id}", + "starred_url": "https://api.github.com/users/hub4j-test-org/starred{/owner}{/repo}", + "subscriptions_url": "https://api.github.com/users/hub4j-test-org/subscriptions", + "organizations_url": "https://api.github.com/users/hub4j-test-org/orgs", + "repos_url": "https://api.github.com/users/hub4j-test-org/repos", + "events_url": "https://api.github.com/users/hub4j-test-org/events{/privacy}", + "received_events_url": "https://api.github.com/users/hub4j-test-org/received_events", + "type": "Organization", + "site_admin": false + }, + "name": "GHApi Test app 2", + "description": "", + "external_url": "https://localhost", + "html_url": "https://github.com/apps/ghapi-test-app-2", + "created_at": "2020-09-30T15:02:20Z", + "updated_at": "2020-09-30T15:02:20Z", + "permissions": {}, + "events": [], + "installations_count": 1 +} \ No newline at end of file diff --git a/src/test/resources/org/kohsuke/github/extras/authorization/JWTTokenProviderTest/wiremock/testAuthorizationHeaderPattern/mappings/app-1.json b/src/test/resources/org/kohsuke/github/extras/authorization/JWTTokenProviderTest/wiremock/testAuthorizationHeaderPattern/mappings/app-1.json new file mode 100644 index 0000000000..f74c924f95 --- /dev/null +++ b/src/test/resources/org/kohsuke/github/extras/authorization/JWTTokenProviderTest/wiremock/testAuthorizationHeaderPattern/mappings/app-1.json @@ -0,0 +1,44 @@ +{ + "id": "bb7cf5bb-45b3-fba2-afd8-939b2c24787a", + "name": "app", + "request": { + "url": "/app", + "method": "GET", + "headers": { + "Authorization": { + "matches": "^Bearer (?ey\\S*)\\.(?\\S*)\\.(?\\S*)$" + }, + "Accept": { + "equalTo": "application/vnd.github.machine-man-preview+json" + } + } + }, + "response": { + "status": 200, + "bodyFileName": "app-1.json", + "headers": { + "Date": "Thu, 05 Nov 2020 20:42:31 GMT", + "Content-Type": "application/json; charset=utf-8", + "Server": "GitHub.com", + "Status": "200 OK", + "Cache-Control": "public, max-age=60, s-maxage=60", + "Vary": [ + "Accept", + "Accept-Encoding, Accept, X-Requested-With", + "Accept-Encoding" + ], + "ETag": "W/\"b3d319dbb4dba93fbda071208d874e5ab566d827e1ad1d7dc59f26d68694dc48\"", + "X-GitHub-Media-Type": "github.v3; param=machine-man-preview; format=json", + "Strict-Transport-Security": "max-age=31536000; includeSubdomains; preload", + "X-Frame-Options": "deny", + "X-Content-Type-Options": "nosniff", + "X-XSS-Protection": "1; mode=block", + "Referrer-Policy": "origin-when-cross-origin, strict-origin-when-cross-origin", + "Content-Security-Policy": "default-src 'none'", + "X-GitHub-Request-Id": "9294:AE05:BDAC761:DB35838:5FA463B6" + } + }, + "uuid": "bb7cf5bb-45b3-fba2-afd8-939b2c24787a", + "persistent": true, + "insertionIndex": 1 +} \ No newline at end of file