feat: adding OAuth support to GitHub factory. (#28)

* Initial work on adding OAuth support to GitHub factory.

Signed-off-by: cccs-eric <eric.ladouceur@cyber.gc.ca>
pull/41/head
cccs-eric 2021-06-25 04:42:20 -04:00 committed by GitHub
parent 0d7a511a67
commit 21edcc0cda
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
15 changed files with 1075 additions and 11 deletions

View File

@ -178,6 +178,7 @@ public class WsMasterModule extends AbstractModule {
install(new org.eclipse.che.api.factory.server.scm.KubernetesScmModule());
install(new org.eclipse.che.api.factory.server.bitbucket.BitbucketServerModule());
install(new org.eclipse.che.api.factory.server.gitlab.GitlabModule());
install(new org.eclipse.che.api.factory.server.github.GithubModule());
bind(org.eclipse.che.api.core.rest.ApiInfoService.class);
bind(org.eclipse.che.api.ssh.server.SshService.class);

View File

@ -26,10 +26,22 @@
<findbugs.failonerror>true</findbugs.failonerror>
</properties>
<dependencies>
<dependency>
<groupId>com.fasterxml.jackson.core</groupId>
<artifactId>jackson-annotations</artifactId>
</dependency>
<dependency>
<groupId>com.fasterxml.jackson.core</groupId>
<artifactId>jackson-databind</artifactId>
</dependency>
<dependency>
<groupId>com.google.guava</groupId>
<artifactId>guava</artifactId>
</dependency>
<dependency>
<groupId>com.google.inject</groupId>
<artifactId>guice</artifactId>
</dependency>
<dependency>
<groupId>javax.inject</groupId>
<artifactId>javax.inject</artifactId>
@ -38,6 +50,14 @@
<groupId>javax.validation</groupId>
<artifactId>validation-api</artifactId>
</dependency>
<dependency>
<groupId>org.eclipse.che.core</groupId>
<artifactId>che-core-api-auth</artifactId>
</dependency>
<dependency>
<groupId>org.eclipse.che.core</groupId>
<artifactId>che-core-api-auth-shared</artifactId>
</dependency>
<dependency>
<groupId>org.eclipse.che.core</groupId>
<artifactId>che-core-api-core</artifactId>
@ -66,11 +86,29 @@
<groupId>org.eclipse.che.core</groupId>
<artifactId>che-core-api-workspace-shared</artifactId>
</dependency>
<dependency>
<groupId>org.eclipse.che.core</groupId>
<artifactId>che-core-commons-lang</artifactId>
</dependency>
<dependency>
<groupId>org.slf4j</groupId>
<artifactId>slf4j-api</artifactId>
</dependency>
<dependency>
<groupId>ch.qos.logback</groupId>
<artifactId>logback-classic</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>com.github.tomakehurst</groupId>
<artifactId>wiremock-jre8-standalone</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>javax.servlet</groupId>
<artifactId>javax.servlet-api</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.eclipse.che.core</groupId>
<artifactId>che-core-commons-json</artifactId>

View File

@ -0,0 +1,198 @@
/*
* Copyright (c) 2012-2021 Red Hat, Inc.
* This program and the accompanying materials are made
* available under the terms of the Eclipse Public License 2.0
* which is available at https://www.eclipse.org/legal/epl-2.0/
*
* SPDX-License-Identifier: EPL-2.0
*
* Contributors:
* Red Hat, Inc. - initial API and implementation
*/
package org.eclipse.che.api.factory.server.github;
import static java.net.HttpURLConnection.HTTP_BAD_REQUEST;
import static java.net.HttpURLConnection.HTTP_NOT_FOUND;
import static java.net.HttpURLConnection.HTTP_NO_CONTENT;
import static java.net.HttpURLConnection.HTTP_OK;
import static java.time.Duration.ofSeconds;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.google.common.base.Charsets;
import com.google.common.base.Splitter;
import com.google.common.io.CharStreams;
import com.google.common.util.concurrent.ThreadFactoryBuilder;
import java.io.IOException;
import java.io.InputStream;
import java.io.InputStreamReader;
import java.io.UncheckedIOException;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.time.Duration;
import java.util.Optional;
import java.util.concurrent.Executors;
import java.util.function.Function;
import org.eclipse.che.api.factory.server.scm.exception.ScmBadRequestException;
import org.eclipse.che.api.factory.server.scm.exception.ScmCommunicationException;
import org.eclipse.che.api.factory.server.scm.exception.ScmItemNotFoundException;
import org.eclipse.che.commons.lang.concurrent.LoggingUncaughtExceptionHandler;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
/** GitHub API operations helper. */
public class GithubApiClient {
private static final Logger LOG = LoggerFactory.getLogger(GithubApiClient.class);
/** GitHub API endpoint URL. */
public static final String GITHUB_API_SERVER = "https://api.github.com";
/** GitHub endpoint URL. */
public static final String GITHUB_SERVER = "https://github.com";
/** GitHub HTTP header containing OAuth scopes. */
public static final String GITHUB_OAUTH_SCOPES_HEADER = "X-OAuth-Scopes";
private final HttpClient httpClient;
private final URI apiServerUrl;
private final URI scmServerUrl;
private static final Duration DEFAULT_HTTP_TIMEOUT = ofSeconds(10);
private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper();
/** Default constructor, binds http client to https://api.github.com */
public GithubApiClient() {
this(GITHUB_API_SERVER);
}
/**
* Used for URL injection in testing.
*
* @param apiServerUrl the GitHub API url
*/
GithubApiClient(final String apiServerUrl) {
this.apiServerUrl = URI.create(apiServerUrl);
this.scmServerUrl = URI.create(GITHUB_SERVER);
this.httpClient =
HttpClient.newBuilder()
.executor(
Executors.newCachedThreadPool(
new ThreadFactoryBuilder()
.setUncaughtExceptionHandler(LoggingUncaughtExceptionHandler.getInstance())
.setNameFormat(GithubApiClient.class.getName() + "-%d")
.setDaemon(true)
.build()))
.connectTimeout(DEFAULT_HTTP_TIMEOUT)
.build();
}
/**
* Returns the user associated with the provided OAuth access token.
*
* @see https://docs.github.com/en/rest/reference/users#get-the-authenticated-user
* @param authenticationToken OAuth access token used by the user.
* @return Information about the user associated with the token
* @throws ScmItemNotFoundException
* @throws ScmCommunicationException
* @throws ScmBadRequestException
*/
public GithubUser getUser(String authenticationToken)
throws ScmItemNotFoundException, ScmCommunicationException, ScmBadRequestException {
final URI uri = apiServerUrl.resolve("/user");
HttpRequest request = buildGithubApiRequest(uri, authenticationToken);
LOG.trace("executeRequest={}", request);
return executeRequest(
httpClient,
request,
response -> {
try {
return OBJECT_MAPPER.readValue(response.body(), GithubUser.class);
} catch (IOException e) {
throw new UncheckedIOException(e);
}
});
}
/**
* Returns the scopes of the OAuth token.
*
* <p>See GitHub documentation at
* https://docs.github.com/en/developers/apps/building-oauth-apps/scopes-for-oauth-apps
*
* @param authenticationToken The OAuth token to inspect.
* @return Array of scopes from the supplied token, empty array if no scope.
* @throws ScmItemNotFoundException
* @throws ScmCommunicationException
* @throws ScmBadRequestException
*/
public String[] getTokenScopes(String authenticationToken)
throws ScmItemNotFoundException, ScmCommunicationException, ScmBadRequestException {
final URI uri = apiServerUrl.resolve("/user");
HttpRequest request = buildGithubApiRequest(uri, authenticationToken);
LOG.trace("executeRequest={}", request);
return executeRequest(
httpClient,
request,
response -> {
Optional<String> scopes = response.headers().firstValue(GITHUB_OAUTH_SCOPES_HEADER);
return Splitter.on(',')
.trimResults()
.omitEmptyStrings()
.splitToList(scopes.orElse(""))
.toArray(String[]::new);
});
}
private HttpRequest buildGithubApiRequest(URI uri, String authenticationToken) {
return HttpRequest.newBuilder(uri)
.headers(
"Authorization",
"token " + authenticationToken,
"Accept",
"application/vnd.github.v3+json")
.timeout(DEFAULT_HTTP_TIMEOUT)
.build();
}
private <T> T executeRequest(
HttpClient httpClient,
HttpRequest request,
Function<HttpResponse<InputStream>, T> responseConverter)
throws ScmBadRequestException, ScmItemNotFoundException, ScmCommunicationException {
try {
HttpResponse<InputStream> response =
httpClient.send(request, HttpResponse.BodyHandlers.ofInputStream());
LOG.trace("executeRequest={} response {}", request, response.statusCode());
if (response.statusCode() == HTTP_OK) {
return responseConverter.apply(response);
} else if (response.statusCode() == HTTP_NO_CONTENT) {
return null;
} else {
String body = CharStreams.toString(new InputStreamReader(response.body(), Charsets.UTF_8));
switch (response.statusCode()) {
case HTTP_BAD_REQUEST:
throw new ScmBadRequestException(body);
case HTTP_NOT_FOUND:
throw new ScmItemNotFoundException(body);
default:
throw new ScmCommunicationException(
"Unexpected status code " + response.statusCode() + " " + response.toString());
}
}
} catch (IOException | InterruptedException | UncheckedIOException e) {
throw new ScmCommunicationException(e.getMessage(), e);
}
}
/**
* Checks if the provided url belongs to this client (GitHub)
*
* @param scmServerUrl the SCM url to verify
* @return If the provided url is recognized by the current client
*/
public boolean isConnected(String scmServerUrl) {
return this.scmServerUrl.equals(URI.create(scmServerUrl));
}
}

View File

@ -0,0 +1,40 @@
/*
* Copyright (c) 2012-2021 Red Hat, Inc.
* This program and the accompanying materials are made
* available under the terms of the Eclipse Public License 2.0
* which is available at https://www.eclipse.org/legal/epl-2.0/
*
* SPDX-License-Identifier: EPL-2.0
*
* Contributors:
* Red Hat, Inc. - initial API and implementation
*/
package org.eclipse.che.api.factory.server.github;
import org.eclipse.che.api.factory.server.scm.AuthorizingFileContentProvider;
import org.eclipse.che.api.factory.server.scm.GitCredentialManager;
import org.eclipse.che.api.factory.server.scm.PersonalAccessTokenManager;
import org.eclipse.che.api.workspace.server.devfile.URLFetcher;
/** Github specific authorizing file content provider. */
class GithubAuthorizingFileContentProvider extends AuthorizingFileContentProvider<GithubUrl> {
GithubAuthorizingFileContentProvider(
GithubUrl githubUrl,
URLFetcher urlFetcher,
GitCredentialManager gitCredentialManager,
PersonalAccessTokenManager personalAccessTokenManager) {
super(githubUrl, urlFetcher, personalAccessTokenManager, gitCredentialManager);
}
/**
* Formatting OAuth token as HTTP Authorization header.
*
* <p>GitHub Authorization HTTP header format is described here:
* https://docs.github.com/en/rest/overview/resources-in-the-rest-api#oauth2-token-sent-in-a-header
*/
@Override
protected String formatAuthorization(String token) {
return "token " + token;
}
}

View File

@ -22,6 +22,8 @@ import javax.validation.constraints.NotNull;
import org.eclipse.che.api.core.ApiException;
import org.eclipse.che.api.core.BadRequestException;
import org.eclipse.che.api.factory.server.DefaultFactoryParameterResolver;
import org.eclipse.che.api.factory.server.scm.GitCredentialManager;
import org.eclipse.che.api.factory.server.scm.PersonalAccessTokenManager;
import org.eclipse.che.api.factory.server.urlfactory.ProjectConfigDtoMerger;
import org.eclipse.che.api.factory.server.urlfactory.URLFactoryBuilder;
import org.eclipse.che.api.factory.shared.dto.FactoryDevfileV2Dto;
@ -42,13 +44,19 @@ import org.eclipse.che.api.workspace.shared.dto.devfile.ProjectDto;
public class GithubFactoryParametersResolver extends DefaultFactoryParameterResolver {
/** Parser which will allow to check validity of URLs and create objects. */
private GithubURLParser githubUrlParser;
private final GithubURLParser githubUrlParser;
/** Builder allowing to build objects from github URL. */
private GithubSourceStorageBuilder githubSourceStorageBuilder;
private final GithubSourceStorageBuilder githubSourceStorageBuilder;
/** ProjectDtoMerger */
private ProjectConfigDtoMerger projectConfigDtoMerger;
private final ProjectConfigDtoMerger projectConfigDtoMerger;
/** Git credential manager. */
private final GitCredentialManager gitCredentialManager;
/** Personal Access Token manager used when fetching protected content. */
private final PersonalAccessTokenManager personalAccessTokenManager;
@Inject
public GithubFactoryParametersResolver(
@ -56,11 +64,15 @@ public class GithubFactoryParametersResolver extends DefaultFactoryParameterReso
URLFetcher urlFetcher,
GithubSourceStorageBuilder githubSourceStorageBuilder,
URLFactoryBuilder urlFactoryBuilder,
ProjectConfigDtoMerger projectConfigDtoMerger) {
ProjectConfigDtoMerger projectConfigDtoMerger,
GitCredentialManager gitCredentialManager,
PersonalAccessTokenManager personalAccessTokenManager) {
super(urlFactoryBuilder, urlFetcher);
this.githubUrlParser = githubUrlParser;
this.githubSourceStorageBuilder = githubSourceStorageBuilder;
this.projectConfigDtoMerger = projectConfigDtoMerger;
this.gitCredentialManager = gitCredentialManager;
this.personalAccessTokenManager = personalAccessTokenManager;
}
/**
@ -93,7 +105,8 @@ public class GithubFactoryParametersResolver extends DefaultFactoryParameterReso
return urlFactoryBuilder
.createFactoryFromDevfile(
githubUrl,
new GithubFileContentProvider(githubUrl, urlFetcher),
new GithubAuthorizingFileContentProvider(
githubUrl, urlFetcher, gitCredentialManager, personalAccessTokenManager),
extractOverrideParams(factoryParameters))
.orElseGet(() -> newDto(FactoryDto.class).withV(CURRENT_VERSION).withSource("repo"))
.acceptVisitor(new GithubFactoryVisitor(githubUrl));

View File

@ -0,0 +1,26 @@
/*
* Copyright (c) 2012-2021 Red Hat, Inc.
* This program and the accompanying materials are made
* available under the terms of the Eclipse Public License 2.0
* which is available at https://www.eclipse.org/legal/epl-2.0/
*
* SPDX-License-Identifier: EPL-2.0
*
* Contributors:
* Red Hat, Inc. - initial API and implementation
*/
package org.eclipse.che.api.factory.server.github;
import com.google.inject.AbstractModule;
import com.google.inject.multibindings.Multibinder;
import org.eclipse.che.api.factory.server.scm.PersonalAccessTokenFetcher;
public class GithubModule extends AbstractModule {
@Override
protected void configure() {
Multibinder<PersonalAccessTokenFetcher> tokenFetcherMultibinder =
Multibinder.newSetBinder(binder(), PersonalAccessTokenFetcher.class);
tokenFetcherMultibinder.addBinding().to(GithubPersonalAccessTokenFetcher.class);
}
}

View File

@ -0,0 +1,235 @@
/*
* Copyright (c) 2012-2021 Red Hat, Inc.
* This program and the accompanying materials are made
* available under the terms of the Eclipse Public License 2.0
* which is available at https://www.eclipse.org/legal/epl-2.0/
*
* SPDX-License-Identifier: EPL-2.0
*
* Contributors:
* Red Hat, Inc. - initial API and implementation
*/
package org.eclipse.che.api.factory.server.github;
import com.google.common.collect.ImmutableMap;
import com.google.common.collect.ImmutableSet;
import java.util.Arrays;
import java.util.Map;
import java.util.Optional;
import java.util.Set;
import javax.inject.Inject;
import javax.inject.Named;
import org.eclipse.che.api.auth.shared.dto.OAuthToken;
import org.eclipse.che.api.core.BadRequestException;
import org.eclipse.che.api.core.ConflictException;
import org.eclipse.che.api.core.ForbiddenException;
import org.eclipse.che.api.core.NotFoundException;
import org.eclipse.che.api.core.ServerException;
import org.eclipse.che.api.core.UnauthorizedException;
import org.eclipse.che.api.factory.server.scm.PersonalAccessToken;
import org.eclipse.che.api.factory.server.scm.PersonalAccessTokenFetcher;
import org.eclipse.che.api.factory.server.scm.exception.ScmBadRequestException;
import org.eclipse.che.api.factory.server.scm.exception.ScmCommunicationException;
import org.eclipse.che.api.factory.server.scm.exception.ScmItemNotFoundException;
import org.eclipse.che.api.factory.server.scm.exception.ScmUnauthorizedException;
import org.eclipse.che.commons.lang.NameGenerator;
import org.eclipse.che.commons.subject.Subject;
import org.eclipse.che.security.oauth.OAuthAPI;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
/** GitHub OAuth token retriever. */
public class GithubPersonalAccessTokenFetcher implements PersonalAccessTokenFetcher {
private static final Logger LOG = LoggerFactory.getLogger(GithubPersonalAccessTokenFetcher.class);
private final String apiEndpoint;
private final OAuthAPI oAuthAPI;
/** GitHub API client. */
private final GithubApiClient githubApiClient;
/** Name of this OAuth provider as found in OAuthAPI. */
private static final String OAUTH_PROVIDER_NAME = "github";
/** Collection of OAuth scopes required to make integration with GitHub work. */
public static final Set<String> DEFAULT_TOKEN_SCOPES = ImmutableSet.of("repo");
/**
* Map of OAuth GitHub scopes where each key is a scope and its value is the parent scope. The
* parent scope includes all of its children scopes. This map is used when determining if a token
* has the required scopes. See
* https://docs.github.com/en/developers/apps/building-oauth-apps/scopes-for-oauth-apps#available-scopes
*/
private static final Map<String, String> SCOPE_MAP =
ImmutableMap.<String, String>builderWithExpectedSize(35)
.put("repo", "repo")
.put("repo:status", "repo")
.put("repo_deployment", "repo")
.put("public_repo", "repo")
.put("repo:invite", "repo")
.put("security_events", "repo")
//
.put("workflow", "workflow")
//
.put("write:packages", "write:packages")
.put("read:packages", "write:packages")
//
.put("delete:packages", "delete:packages")
//
.put("admin:org", "admin:org")
.put("write:org", "admin:org")
.put("read:org", "admin:org")
//
.put("admin:public_key", "admin:public_key")
.put("write:public_key", "admin:public_key")
.put("read:public_key", "admin:public_key")
//
.put("admin:repo_hook", "admin:repo_hook")
.put("write:repo_hook", "admin:repo_hook")
.put("read:repo_hook", "admin:repo_hook")
//
.put("admin:org_hook", "admin:org_hook")
//
.put("gist", "gist")
//
.put("notifications", "notifications")
//
.put("user", "user")
.put("read:user", "user")
.put("user:email", "user")
.put("user:follow", "user")
//
.put("delete_repo", "delete_repo")
//
.put("write:discussion", "write:discussion")
.put("read:discussion", "write:discussion")
//
.put("admin:enterprise", "admin:enterprise")
.put("manage_billing:enterprise", "admin:enterprise")
.put("read:enterprise", "admin:enterprise")
//
.put("admin:gpg_key", "admin:gpg_key")
.put("write:gpg_key", "admin:gpg_key")
.put("read:gpg_key", "admin:gpg_key")
.build();
@Inject
public GithubPersonalAccessTokenFetcher(@Named("che.api") String apiEndpoint, OAuthAPI oAuthAPI) {
this(apiEndpoint, oAuthAPI, new GithubApiClient());
}
/**
* Constructor used for testing only.
*
* @param apiEndpoint
* @param oAuthAPI
* @param githubApiClient
*/
GithubPersonalAccessTokenFetcher(
String apiEndpoint, OAuthAPI oAuthAPI, GithubApiClient githubApiClient) {
this.apiEndpoint = apiEndpoint;
this.oAuthAPI = oAuthAPI;
this.githubApiClient = githubApiClient;
}
@Override
public PersonalAccessToken fetchPersonalAccessToken(Subject cheSubject, String scmServerUrl)
throws ScmUnauthorizedException, ScmCommunicationException {
OAuthToken oAuthToken;
try {
oAuthToken = oAuthAPI.getToken(OAUTH_PROVIDER_NAME);
// Find the user associated to the OAuth token by querying the GitHub API.
GithubUser user = githubApiClient.getUser(oAuthToken.getToken());
PersonalAccessToken token =
new PersonalAccessToken(
scmServerUrl,
cheSubject.getUserId(),
user.getLogin(),
Long.toString(user.getId()),
NameGenerator.generate("oauth2-", 5),
NameGenerator.generate("id-", 5),
oAuthToken.getToken());
Optional<Boolean> valid = isValid(token);
if (valid.isEmpty()) {
throw new ScmCommunicationException(
"Unable to verify if current token is a valid GitHub token. Token's scm-url needs to be '"
+ GithubApiClient.GITHUB_SERVER
+ "' and was '"
+ token.getScmProviderUrl()
+ "'");
} else if (!valid.get()) {
throw new ScmCommunicationException(
"Current token doesn't have the necessary privileges. Please make sure Che app scopes are correct and containing at least: "
+ DEFAULT_TOKEN_SCOPES.toString());
}
return token;
} catch (UnauthorizedException e) {
throw new ScmUnauthorizedException(
cheSubject.getUserName()
+ " is not authorized in "
+ OAUTH_PROVIDER_NAME
+ " OAuth provider.",
OAUTH_PROVIDER_NAME,
"2.0",
getLocalAuthenticateUrl());
} catch (NotFoundException
| ServerException
| ForbiddenException
| BadRequestException
| ScmItemNotFoundException
| ScmBadRequestException
| ConflictException e) {
LOG.error(e.getMessage(), e);
throw new ScmCommunicationException(e.getMessage(), e);
}
}
@Override
public Optional<Boolean> isValid(PersonalAccessToken personalAccessToken)
throws ScmCommunicationException, ScmUnauthorizedException {
if (!githubApiClient.isConnected(personalAccessToken.getScmProviderUrl())) {
LOG.debug("not a valid url {} for current fetcher ", personalAccessToken.getScmProviderUrl());
return Optional.empty();
}
try {
String[] scopes = githubApiClient.getTokenScopes(personalAccessToken.getToken());
return Optional.of(Boolean.valueOf(containsScopes(scopes, DEFAULT_TOKEN_SCOPES)));
} catch (ScmItemNotFoundException | ScmCommunicationException | ScmBadRequestException e) {
LOG.error(e.getMessage(), e);
throw new ScmCommunicationException(e.getMessage(), e);
}
}
/**
* Checks if the tokenScopes array contains the requiredScopes.
*
* @param tokenScopes Scopes from token
* @param requiredScopes Mandatory scopes
* @return If all mandatory scopes are contained in the token's scopes
*/
boolean containsScopes(String[] tokenScopes, Set<String> requiredScopes) {
Arrays.sort(tokenScopes);
// We need check that the token has the required minimal scopes. The scopes can be normalized
// by GitHub, so we need to be careful for sub-scopes being included in parent scopes.
for (String requiredScope : requiredScopes) {
String parentScope = SCOPE_MAP.get(requiredScope);
if (parentScope == null) {
// requiredScope is not recognized as a GitHub scope, so just skip it.
continue;
}
if (Arrays.binarySearch(tokenScopes, parentScope) < 0
&& Arrays.binarySearch(tokenScopes, requiredScope) < 0) {
return false;
}
}
return true;
}
private String getLocalAuthenticateUrl() {
return apiEndpoint
+ "/oauth/authenticate?oauth_provider="
+ OAUTH_PROVIDER_NAME
+ "&request_method=POST&signature_method=rsa";
}
}

View File

@ -0,0 +1,93 @@
/*
* Copyright (c) 2012-2021 Red Hat, Inc.
* This program and the accompanying materials are made
* available under the terms of the Eclipse Public License 2.0
* which is available at https://www.eclipse.org/legal/epl-2.0/
*
* SPDX-License-Identifier: EPL-2.0
*
* Contributors:
* Red Hat, Inc. - initial API and implementation
*/
package org.eclipse.che.api.factory.server.github;
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
import java.util.Objects;
@JsonIgnoreProperties(ignoreUnknown = true)
public class GithubUser {
private long id;
private String login;
private String email;
private String name;
public long getId() {
return id;
}
public void setId(long id) {
this.id = id;
}
public String getLogin() {
return login;
}
public void setLogin(String login) {
this.login = login;
}
public String getEmail() {
return email;
}
public void setEmail(String email) {
this.email = email;
}
public String getName() {
return name;
}
public void setName(String name) {
this.name = name;
}
@Override
public String toString() {
return "GithubUser{"
+ "id="
+ id
+ ", login='"
+ login
+ '\''
+ ", email='"
+ email
+ '\''
+ ", name='"
+ name
+ '\''
+ '}';
}
@Override
public boolean equals(Object o) {
if (this == o) {
return true;
}
if (o == null || getClass() != o.getClass()) {
return false;
}
GithubUser that = (GithubUser) o;
return id == that.id
&& Objects.equals(login, that.login)
&& Objects.equals(email, that.email)
&& Objects.equals(name, that.name);
}
@Override
public int hashCode() {
return Objects.hash(id, login, email, name);
}
}

View File

@ -0,0 +1,149 @@
/*
* Copyright (c) 2012-2021 Red Hat, Inc.
* This program and the accompanying materials are made
* available under the terms of the Eclipse Public License 2.0
* which is available at https://www.eclipse.org/legal/epl-2.0/
*
* SPDX-License-Identifier: EPL-2.0
*
* Contributors:
* Red Hat, Inc. - initial API and implementation
*/
package org.eclipse.che.api.factory.server.github;
import static com.github.tomakehurst.wiremock.client.WireMock.aResponse;
import static com.github.tomakehurst.wiremock.client.WireMock.equalTo;
import static com.github.tomakehurst.wiremock.client.WireMock.get;
import static com.github.tomakehurst.wiremock.client.WireMock.stubFor;
import static com.github.tomakehurst.wiremock.client.WireMock.urlEqualTo;
import static com.github.tomakehurst.wiremock.core.WireMockConfiguration.wireMockConfig;
import static org.testng.Assert.assertEquals;
import static org.testng.Assert.assertEqualsNoOrder;
import static org.testng.Assert.assertFalse;
import static org.testng.Assert.assertNotNull;
import static org.testng.Assert.assertTrue;
import com.github.tomakehurst.wiremock.WireMockServer;
import com.github.tomakehurst.wiremock.client.WireMock;
import com.github.tomakehurst.wiremock.common.Slf4jNotifier;
import com.google.common.net.HttpHeaders;
import org.mockito.testng.MockitoTestNGListener;
import org.testng.annotations.AfterMethod;
import org.testng.annotations.BeforeMethod;
import org.testng.annotations.Listeners;
import org.testng.annotations.Test;
@Listeners(MockitoTestNGListener.class)
public class GithubApiClientTest {
private GithubApiClient client;
WireMockServer wireMockServer;
WireMock wireMock;
@BeforeMethod
void start() {
wireMockServer =
new WireMockServer(wireMockConfig().notifier(new Slf4jNotifier(false)).dynamicPort());
wireMockServer.start();
WireMock.configureFor("localhost", wireMockServer.port());
wireMock = new WireMock("localhost", wireMockServer.port());
client = new GithubApiClient(wireMockServer.url("/"));
}
@AfterMethod
void stop() {
wireMockServer.stop();
}
@Test
public void testGetUser() throws Exception {
stubFor(
get(urlEqualTo("/user"))
.withHeader(HttpHeaders.AUTHORIZATION, equalTo("token token1"))
.willReturn(
aResponse()
.withHeader("Content-Type", "application/json; charset=utf-8")
.withBodyFile("github/rest/user/response.json")));
GithubUser user = client.getUser("token1");
assertNotNull(user, "GitHub API should have returned a non-null user object");
assertEquals(user.getId(), 123456789, "GitHub user id was not parsed properly by client");
assertEquals(
user.getLogin(), "github-user", "GitHub user login was not parsed properly by client");
assertEquals(
user.getEmail(),
"github-user@acme.com",
"GitHub user email was not parsed properly by client");
assertEquals(
user.getName(), "Github User", "GitHub user name was not parsed properly by client");
}
@Test
public void testGetTokenScopes() throws Exception {
stubFor(
get(urlEqualTo("/user"))
.withHeader(HttpHeaders.AUTHORIZATION, equalTo("token token1"))
.willReturn(
aResponse()
.withHeader("Content-Type", "application/json; charset=utf-8")
.withHeader(GithubApiClient.GITHUB_OAUTH_SCOPES_HEADER, "repo, user:email")
.withBodyFile("github/rest/user/response.json")));
String[] scopes = client.getTokenScopes("token1");
String[] expectedScopes = {"repo", "user:email"};
assertNotNull(scopes, "GitHub API should have returned a non-null scope array");
assertEqualsNoOrder(
scopes, expectedScopes, "Returned scope array does not match expected values");
}
@Test
public void testGetTokenScopesWithNoScopeHeader() throws Exception {
stubFor(
get(urlEqualTo("/user"))
.withHeader(HttpHeaders.AUTHORIZATION, equalTo("token token1"))
.willReturn(
aResponse()
.withHeader("Content-Type", "application/json; charset=utf-8")
.withBodyFile("github/rest/user/response.json")));
String[] scopes = client.getTokenScopes("token1");
assertNotNull(scopes, "GitHub API should have returned a non-null scope array");
assertEquals(
scopes.length,
0,
"A response with no "
+ GithubApiClient.GITHUB_OAUTH_SCOPES_HEADER
+ " header should return an empty array");
}
@Test
public void testGetTokenScopesWithNoScope() throws Exception {
stubFor(
get(urlEqualTo("/user"))
.withHeader(HttpHeaders.AUTHORIZATION, equalTo("token token1"))
.willReturn(
aResponse()
.withHeader("Content-Type", "application/json; charset=utf-8")
.withHeader(GithubApiClient.GITHUB_OAUTH_SCOPES_HEADER, "")
.withBodyFile("github/rest/user/response.json")));
String[] scopes = client.getTokenScopes("token1");
assertNotNull(scopes, "GitHub API should have returned a non-null scope array");
assertEquals(
scopes.length,
0,
"A response with empty "
+ GithubApiClient.GITHUB_OAUTH_SCOPES_HEADER
+ " header should return an empty array");
}
@Test
public void shouldReturnFalseOnConnectedToOtherHost() {
assertFalse(client.isConnected("https://other.com"));
}
@Test
public void shouldReturnTrueWhenConnectedToGithub() {
assertTrue(client.isConnected("https://github.com"));
}
}

View File

@ -14,18 +14,30 @@ package org.eclipse.che.api.factory.server.github;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.verify;
import org.eclipse.che.api.factory.server.scm.GitCredentialManager;
import org.eclipse.che.api.factory.server.scm.PersonalAccessTokenManager;
import org.eclipse.che.api.workspace.server.devfile.FileContentProvider;
import org.eclipse.che.api.workspace.server.devfile.URLFetcher;
import org.mockito.Mock;
import org.mockito.Mockito;
import org.mockito.testng.MockitoTestNGListener;
import org.testng.annotations.Listeners;
import org.testng.annotations.Test;
public class GithubFileContentProviderTest {
@Listeners(MockitoTestNGListener.class)
public class GithubAuthorizingFileContentProviderTest {
@Mock private GitCredentialManager gitCredentialManager;
@Mock private PersonalAccessTokenManager personalAccessTokenManager;
@Test
public void shouldExpandRelativePaths() throws Exception {
URLFetcher urlFetcher = Mockito.mock(URLFetcher.class);
GithubUrl githubUrl = new GithubUrl().withUsername("eclipse").withRepository("che");
FileContentProvider fileContentProvider = new GithubFileContentProvider(githubUrl, urlFetcher);
FileContentProvider fileContentProvider =
new GithubAuthorizingFileContentProvider(
githubUrl, urlFetcher, gitCredentialManager, personalAccessTokenManager);
fileContentProvider.fetchContent("devfile.yaml");
verify(urlFetcher).fetch(eq("https://raw.githubusercontent.com/eclipse/che/HEAD/devfile.yaml"));
}
@ -34,7 +46,9 @@ public class GithubFileContentProviderTest {
public void shouldPreserveAbsolutePaths() throws Exception {
URLFetcher urlFetcher = Mockito.mock(URLFetcher.class);
GithubUrl githubUrl = new GithubUrl().withUsername("eclipse").withRepository("che");
FileContentProvider fileContentProvider = new GithubFileContentProvider(githubUrl, urlFetcher);
FileContentProvider fileContentProvider =
new GithubAuthorizingFileContentProvider(
githubUrl, urlFetcher, gitCredentialManager, personalAccessTokenManager);
String url = "https://raw.githubusercontent.com/foo/bar/devfile.yaml";
fileContentProvider.fetchContent(url);
verify(urlFetcher).fetch(eq(url));

View File

@ -33,6 +33,8 @@ import java.util.Collections;
import java.util.Map;
import java.util.Optional;
import org.eclipse.che.api.core.model.factory.ScmInfo;
import org.eclipse.che.api.factory.server.scm.GitCredentialManager;
import org.eclipse.che.api.factory.server.scm.PersonalAccessTokenManager;
import org.eclipse.che.api.factory.server.urlfactory.DevfileFilenamesProvider;
import org.eclipse.che.api.factory.server.urlfactory.ProjectConfigDtoMerger;
import org.eclipse.che.api.factory.server.urlfactory.RemoteFactoryUrl;
@ -79,6 +81,11 @@ public class GithubFactoryParametersResolverTest {
/** Parser which will allow to check validity of URLs and create objects. */
@Mock private URLFactoryBuilder urlFactoryBuilder;
// TODO: Verify if we should add test cases involving credential manager and patManager
@Mock private GitCredentialManager gitCredentialManager;
@Mock private PersonalAccessTokenManager personalAccessTokenManager;
/**
* Capturing the location parameter when calling {@link
* URLFactoryBuilder#createFactoryFromDevfile(RemoteFactoryUrl, FileContentProvider, Map)}
@ -98,7 +105,9 @@ public class GithubFactoryParametersResolverTest {
urlFetcher,
githubSourceStorageBuilder,
urlFactoryBuilder,
projectConfigDtoMerger);
projectConfigDtoMerger,
gitCredentialManager,
personalAccessTokenManager);
assertNotNull(this.githubFactoryParametersResolver);
}

View File

@ -0,0 +1,183 @@
/*
* Copyright (c) 2012-2021 Red Hat, Inc.
* This program and the accompanying materials are made
* available under the terms of the Eclipse Public License 2.0
* which is available at https://www.eclipse.org/legal/epl-2.0/
*
* SPDX-License-Identifier: EPL-2.0
*
* Contributors:
* Red Hat, Inc. - initial API and implementation
*/
package org.eclipse.che.api.factory.server.github;
import static com.github.tomakehurst.wiremock.client.WireMock.aResponse;
import static com.github.tomakehurst.wiremock.client.WireMock.equalTo;
import static com.github.tomakehurst.wiremock.client.WireMock.get;
import static com.github.tomakehurst.wiremock.client.WireMock.stubFor;
import static com.github.tomakehurst.wiremock.client.WireMock.urlEqualTo;
import static com.github.tomakehurst.wiremock.core.WireMockConfiguration.wireMockConfig;
import static org.eclipse.che.dto.server.DtoFactory.newDto;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.Mockito.when;
import static org.testng.Assert.*;
import com.github.tomakehurst.wiremock.WireMockServer;
import com.github.tomakehurst.wiremock.client.WireMock;
import com.github.tomakehurst.wiremock.common.Slf4jNotifier;
import com.google.common.collect.ImmutableSet;
import com.google.common.net.HttpHeaders;
import java.util.Collections;
import org.eclipse.che.api.auth.shared.dto.OAuthToken;
import org.eclipse.che.api.core.UnauthorizedException;
import org.eclipse.che.api.factory.server.scm.PersonalAccessToken;
import org.eclipse.che.api.factory.server.scm.exception.ScmCommunicationException;
import org.eclipse.che.api.factory.server.scm.exception.ScmUnauthorizedException;
import org.eclipse.che.commons.subject.Subject;
import org.eclipse.che.commons.subject.SubjectImpl;
import org.eclipse.che.security.oauth.OAuthAPI;
import org.mockito.Mock;
import org.mockito.testng.MockitoTestNGListener;
import org.testng.annotations.AfterMethod;
import org.testng.annotations.BeforeMethod;
import org.testng.annotations.Listeners;
import org.testng.annotations.Test;
@Listeners(MockitoTestNGListener.class)
public class GithubPersonalAccessTokenFetcherTest {
@Mock OAuthAPI oAuthAPI;
GithubPersonalAccessTokenFetcher githubPATFetcher;
final int httpPort = 3301;
WireMockServer wireMockServer;
WireMock wireMock;
final String githubOauthToken = "gho_token1";
@BeforeMethod
void start() {
wireMockServer =
new WireMockServer(wireMockConfig().notifier(new Slf4jNotifier(false)).port(httpPort));
wireMockServer.start();
WireMock.configureFor("localhost", httpPort);
wireMock = new WireMock("localhost", httpPort);
githubPATFetcher =
new GithubPersonalAccessTokenFetcher(
"http://che.api", oAuthAPI, new GithubApiClient(wireMockServer.url("/")));
}
@AfterMethod
void stop() {
wireMockServer.stop();
}
@Test
public void shouldNotValidateSCMServerWithTrailingSlash() throws Exception {
stubFor(
get(urlEqualTo("/user"))
.withHeader(HttpHeaders.AUTHORIZATION, equalTo("token " + githubOauthToken))
.willReturn(
aResponse()
.withHeader("Content-Type", "application/json; charset=utf-8")
.withHeader(GithubApiClient.GITHUB_OAUTH_SCOPES_HEADER, "repo")
.withBodyFile("github/rest/user/response.json")));
PersonalAccessToken personalAccessToken =
new PersonalAccessToken(
"https://github.com/",
"cheUserId",
"scmUserName",
"scmUserId",
"scmTokenName",
"scmTokenId",
githubOauthToken);
assertTrue(
githubPATFetcher.isValid(personalAccessToken).isEmpty(),
"Should not validate SCM server with trailing /");
}
@Test
public void testContainsScope() {
String[] tokenScopes = {"repo", "notifications", "write:org", "admin:gpg_key"};
assertTrue(
githubPATFetcher.containsScopes(tokenScopes, ImmutableSet.of("repo")),
"'repo' scope should have matched directly.");
assertTrue(
githubPATFetcher.containsScopes(tokenScopes, ImmutableSet.of("public_repo")),
"'public_repo' scope should have matched since token has parent scope 'repo'.");
assertTrue(
githubPATFetcher.containsScopes(
tokenScopes, ImmutableSet.of("read:gpg_key", "write:gpg_key")),
"'admin:gpg_key' token scope should cover both scope requirement.");
assertFalse(
githubPATFetcher.containsScopes(tokenScopes, ImmutableSet.of("admin:org")),
"'admin:org' scope should not match since token only has scope 'write:org'.");
assertFalse(
githubPATFetcher.containsScopes(tokenScopes, ImmutableSet.of("gist")),
"'gist' shouldn't matche since it is not present in token scope");
assertTrue(
githubPATFetcher.containsScopes(tokenScopes, ImmutableSet.of("unknown", "repo")),
"'unknown' is not even a valid GitHub scope, so it shouldn't have any impact.");
assertTrue(
githubPATFetcher.containsScopes(tokenScopes, Collections.emptySet()),
"No required scope should always return true");
assertFalse(
githubPATFetcher.containsScopes(new String[0], ImmutableSet.of("repo")),
"Token has no scope, so it should not match");
assertTrue(
githubPATFetcher.containsScopes(new String[0], Collections.emptySet()),
"No scope requirement and a token with no scope should match");
}
@Test(
expectedExceptions = ScmCommunicationException.class,
expectedExceptionsMessageRegExp =
"Current token doesn't have the necessary privileges. Please make sure Che app scopes are correct and containing at least: \\[repo\\]")
public void shouldThrowExceptionOnInsufficientTokenScopes() throws Exception {
Subject subject = new SubjectImpl("Username", "id1", "token", false);
OAuthToken oAuthToken = newDto(OAuthToken.class).withToken(githubOauthToken).withScope("");
when(oAuthAPI.getToken(anyString())).thenReturn(oAuthToken);
stubFor(
get(urlEqualTo("/user"))
.withHeader(HttpHeaders.AUTHORIZATION, equalTo("token " + githubOauthToken))
.willReturn(
aResponse()
.withHeader("Content-Type", "application/json; charset=utf-8")
.withHeader(GithubApiClient.GITHUB_OAUTH_SCOPES_HEADER, "")
.withBodyFile("github/rest/user/response.json")));
githubPATFetcher.fetchPersonalAccessToken(subject, GithubApiClient.GITHUB_SERVER);
}
@Test(
expectedExceptions = ScmUnauthorizedException.class,
expectedExceptionsMessageRegExp = "Username is not authorized in github OAuth provider.")
public void shouldThrowUnauthorizedExceptionWhenUserNotLoggedIn() throws Exception {
Subject subject = new SubjectImpl("Username", "id1", "token", false);
when(oAuthAPI.getToken(anyString())).thenThrow(UnauthorizedException.class);
githubPATFetcher.fetchPersonalAccessToken(subject, GithubApiClient.GITHUB_SERVER);
}
@Test
public void shouldReturnToken() throws Exception {
Subject subject = new SubjectImpl("Username", "id1", "token", false);
OAuthToken oAuthToken = newDto(OAuthToken.class).withToken(githubOauthToken).withScope("repo");
when(oAuthAPI.getToken(anyString())).thenReturn(oAuthToken);
stubFor(
get(urlEqualTo("/user"))
.withHeader(HttpHeaders.AUTHORIZATION, equalTo("token " + githubOauthToken))
.willReturn(
aResponse()
.withHeader("Content-Type", "application/json; charset=utf-8")
.withHeader(GithubApiClient.GITHUB_OAUTH_SCOPES_HEADER, "repo")
.withBodyFile("github/rest/user/response.json")));
PersonalAccessToken token =
githubPATFetcher.fetchPersonalAccessToken(subject, GithubApiClient.GITHUB_SERVER);
assertNotNull(token);
}
}

View File

@ -0,0 +1,34 @@
{
"login": "github-user",
"id": 123456789,
"node_id": "ddfsSDSDDJKHSDjhd",
"avatar_url": "https://avatars.githubusercontent.com/u/123456789?v=4",
"gravatar_id": "",
"url": "https://api.github.com/users/github-user",
"html_url": "https://github.com/github-user",
"followers_url": "https://api.github.com/users/github-user/followers",
"following_url": "https://api.github.com/users/github-user/following{/other_user}",
"gists_url": "https://api.github.com/users/github-user/gists{/gist_id}",
"starred_url": "https://api.github.com/users/github-user/starred{/owner}{/repo}",
"subscriptions_url": "https://api.github.com/users/github-user/subscriptions",
"organizations_url": "https://api.github.com/users/github-user/orgs",
"repos_url": "https://api.github.com/users/github-user/repos",
"events_url": "https://api.github.com/users/github-user/events{/privacy}",
"received_events_url": "https://api.github.com/users/github-user/received_events",
"type": "User",
"site_admin": false,
"name": "Github User",
"company": "ACME",
"blog": "https://acme.com/",
"location": "Planet Earth",
"email": "github-user@acme.com",
"hireable": null,
"bio": null,
"twitter_username": null,
"public_repos": 12,
"public_gists": 3,
"followers": 1,
"following": 0,
"created_at": "2019-10-11T15:46:45Z",
"updated_at": "2021-06-21T12:38:04Z"
}

View File

@ -0,0 +1,26 @@
<?xml version="1.0" encoding="UTF-8"?>
<!--
Copyright (c) 2012-2021 Red Hat, Inc.
This program and the accompanying materials are made
available under the terms of the Eclipse Public License 2.0
which is available at https://www.eclipse.org/legal/epl-2.0/
SPDX-License-Identifier: EPL-2.0
Contributors:
Red Hat, Inc. - initial API and implementation
-->
<configuration>
<appender name="stdout" class="ch.qos.logback.core.ConsoleAppender">
<encoder>
<pattern>%-41(%date[%.15thread]) %-45([%-5level] [%.30logger{30} %L]) - %msg%n%nopex</pattern>
</encoder>
</appender>
<root level="INFO">
<appender-ref ref="stdout"/>
</root>
</configuration>

View File

@ -68,7 +68,8 @@ public class AuthorizingFileContentProvider<T extends RemoteFactoryUrl>
EnvironmentContext.getCurrent().getSubject(), remoteFactoryUrl.getHostName());
if (token.isPresent()) {
PersonalAccessToken personalAccessToken = token.get();
String content = urlFetcher.fetch(requestURL, "Bearer " + personalAccessToken.getToken());
String content =
urlFetcher.fetch(requestURL, formatAuthorization(personalAccessToken.getToken()));
gitCredentialManager.createOrReplace(personalAccessToken);
return content;
} else {
@ -81,7 +82,7 @@ public class AuthorizingFileContentProvider<T extends RemoteFactoryUrl>
personalAccessTokenManager.fetchAndSave(
EnvironmentContext.getCurrent().getSubject(), remoteFactoryUrl.getHostName());
String content =
urlFetcher.fetch(requestURL, "Bearer " + personalAccessToken.getToken());
urlFetcher.fetch(requestURL, formatAuthorization(personalAccessToken.getToken()));
gitCredentialManager.createOrReplace(personalAccessToken);
return content;
} catch (ScmUnauthorizedException
@ -109,4 +110,8 @@ public class AuthorizingFileContentProvider<T extends RemoteFactoryUrl>
throw new DevfileException(e.getMessage(), e);
}
}
protected String formatAuthorization(String token) {
return "Bearer " + token;
}
}