Skip to content

Commit

Permalink
Github oauth backend. (#7237)
Browse files Browse the repository at this point in the history
  • Loading branch information
Dmytro committed Oct 21, 2021
1 parent a35f93f commit b230bc5
Show file tree
Hide file tree
Showing 10 changed files with 267 additions and 11 deletions.
Expand Up @@ -175,12 +175,13 @@ private Map<String, Object> completeOAuthFlow(final String clientId,
final String redirectUrl,
JsonNode oAuthParamConfig)
throws IOException {
var accessTokenUrl = getAccessTokenUrl(oAuthParamConfig);
var accessTokenUrl = getAccessTokenUrl();
final HttpRequest request = HttpRequest.newBuilder()
.POST(HttpRequest.BodyPublishers
.ofString(tokenReqContentType.converter.apply(getAccessTokenQueryParameters(clientId, clientSecret, authCode, redirectUrl))))
.uri(URI.create(accessTokenUrl))
.header("Content-Type", tokenReqContentType.contentType)
.header("Accept", "application/json")
.build();
// TODO: Handle error response to report better messages
try {
Expand Down Expand Up @@ -220,7 +221,7 @@ protected String extractCodeParameter(Map<String, Object> queryParams) throws IO
/**
* Returns the URL where to retrieve the access token from.
*/
protected abstract String getAccessTokenUrl(JsonNode oAuthParamConfig);
protected abstract String getAccessTokenUrl();

protected Map<String, Object> extractRefreshToken(final JsonNode data, String accessTokenUrl) throws IOException {
final Map<String, Object> result = new HashMap<>();
Expand Down
Expand Up @@ -8,6 +8,7 @@
import io.airbyte.config.persistence.ConfigRepository;
import io.airbyte.oauth.flows.AsanaOAuthFlow;
import io.airbyte.oauth.flows.FacebookMarketingOAuthFlow;
import io.airbyte.oauth.flows.GithubOAuthFlow;
import io.airbyte.oauth.flows.SalesforceOAuthFlow;
import io.airbyte.oauth.flows.TrelloOAuthFlow;
import io.airbyte.oauth.flows.google.GoogleAdsOAuthFlow;
Expand All @@ -24,6 +25,7 @@ public OAuthImplementationFactory(final ConfigRepository configRepository) {
OAUTH_FLOW_MAPPING = ImmutableMap.<String, OAuthFlowImplementation>builder()
.put("airbyte/source-asana", new AsanaOAuthFlow(configRepository))
.put("airbyte/source-facebook-marketing", new FacebookMarketingOAuthFlow(configRepository))
.put("airbyte/source-github", new GithubOAuthFlow(configRepository))
.put("airbyte/source-google-ads", new GoogleAdsOAuthFlow(configRepository))
.put("airbyte/source-google-analytics-v4", new GoogleAnalyticsOAuthFlow(configRepository))
.put("airbyte/source-google-search-console", new GoogleSearchConsoleOAuthFlow(configRepository))
Expand Down
Expand Up @@ -4,7 +4,6 @@

package io.airbyte.oauth.flows;

import com.fasterxml.jackson.databind.JsonNode;
import com.google.common.annotations.VisibleForTesting;
import com.google.common.collect.ImmutableMap;
import io.airbyte.config.persistence.ConfigRepository;
Expand Down Expand Up @@ -49,7 +48,7 @@ protected String formatConsentUrl(UUID definitionId, String clientId, String red
}

@Override
protected String getAccessTokenUrl(JsonNode oAuthParamConfig) {
protected String getAccessTokenUrl() {
return ACCESS_TOKEN_URL;
}

Expand Down
Expand Up @@ -54,7 +54,7 @@ protected String formatConsentUrl(final UUID definitionId, final String clientId
}

@Override
protected String getAccessTokenUrl(JsonNode oAuthParamConfig) {
protected String getAccessTokenUrl() {
return ACCESS_TOKEN_URL;
}

Expand Down
@@ -0,0 +1,82 @@
/*
* Copyright (c) 2021 Airbyte, Inc., all rights reserved.
*/

package io.airbyte.oauth.flows;

import com.fasterxml.jackson.databind.JsonNode;
import com.google.common.annotations.VisibleForTesting;
import com.google.common.base.Preconditions;
import io.airbyte.config.persistence.ConfigRepository;
import io.airbyte.oauth.BaseOAuthFlow;
import java.io.IOException;
import java.net.URISyntaxException;
import java.net.http.HttpClient;
import java.util.Map;
import java.util.UUID;
import java.util.function.Supplier;
import org.apache.http.client.utils.URIBuilder;

/**
* Following docs from
* https://docs.github.com/en/developers/apps/building-oauth-apps/authorizing-oauth-apps#web-application-flow
*/
public class GithubOAuthFlow extends BaseOAuthFlow {

private static final String AUTHORIZE_URL = "https://github.com/login/oauth/authorize";
private static final String ACCESS_TOKEN_URL = "https://github.com/login/oauth/access_token";

public GithubOAuthFlow(final ConfigRepository configRepository) {
super(configRepository);
}

@VisibleForTesting
GithubOAuthFlow(final ConfigRepository configRepository, final HttpClient httpClient, final Supplier<String> stateSupplier) {
super(configRepository, httpClient, stateSupplier);
}

@Override
protected String formatConsentUrl(final UUID definitionId, final String clientId, final String redirectUrl) throws IOException {
try {
// No scope means read-only access to public information
// https://docs.github.com/en/developers/apps/building-oauth-apps/scopes-for-oauth-apps#available-scopes
return new URIBuilder(AUTHORIZE_URL)
.addParameter("client_id", clientId)
.addParameter("redirect_uri", redirectUrl)
.addParameter("state", getState())
.build().toString();
} catch (URISyntaxException e) {
throw new IOException("Failed to format Consent URL for OAuth flow", e);
}
}

@Override
protected String getAccessTokenUrl() {
return ACCESS_TOKEN_URL;
}

@Override
protected Map<String, Object> extractRefreshToken(final JsonNode data, String accessTokenUrl) throws IOException {
System.out.println(data);
if (data.has("access_token")) {
return Map.of("credentials", Map.of("access_token", data.get("access_token").asText()));
} else {
throw new IOException(String.format("Missing 'access_token' in query params from %s", ACCESS_TOKEN_URL));
}
}

@Override
protected String getClientIdUnsafe(final JsonNode config) {
// the config object containing client ID and secret is nested inside the "credentials" object
Preconditions.checkArgument(config.hasNonNull("credentials"));
return super.getClientIdUnsafe(config.get("credentials"));
}

@Override
protected String getClientSecretUnsafe(final JsonNode config) {
// the config object containing client ID and secret is nested inside the "credentials" object
Preconditions.checkArgument(config.hasNonNull("credentials"));
return super.getClientSecretUnsafe(config.get("credentials"));
}

}
Expand Up @@ -51,7 +51,7 @@ protected String formatConsentUrl(UUID definitionId, String clientId, String red
}

@Override
protected String getAccessTokenUrl(JsonNode oAuthConfig) {
protected String getAccessTokenUrl() {
return ACCESS_TOKEN_URL;
}

Expand Down
Expand Up @@ -4,7 +4,6 @@

package io.airbyte.oauth.flows.google;

import com.fasterxml.jackson.databind.JsonNode;
import com.google.common.annotations.VisibleForTesting;
import com.google.common.collect.ImmutableMap;
import io.airbyte.config.persistence.ConfigRepository;
Expand Down Expand Up @@ -63,7 +62,7 @@ protected String formatConsentUrl(final UUID definitionId, final String clientId
protected abstract String getScope();

@Override
protected String getAccessTokenUrl(JsonNode oAuthParamConfig) {
protected String getAccessTokenUrl() {
return ACCESS_TOKEN_URL;
}

Expand Down
@@ -0,0 +1,84 @@
/*
* Copyright (c) 2021 Airbyte, Inc., all rights reserved.
*/

package io.airbyte.oauth.flows;

import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.Mockito.when;

import com.fasterxml.jackson.databind.JsonNode;
import com.google.common.collect.ImmutableMap;
import io.airbyte.commons.json.Jsons;
import io.airbyte.config.SourceOAuthParameter;
import io.airbyte.config.persistence.ConfigNotFoundException;
import io.airbyte.config.persistence.ConfigRepository;
import io.airbyte.oauth.OAuthFlowImplementation;
import io.airbyte.validation.json.JsonValidationException;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.List;
import java.util.Map;
import java.util.UUID;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;

public class GithubOAuthFlowIntegrationTest extends OAuthFlowIntegrationTest {

protected static final Path CREDENTIALS_PATH = Path.of("secrets/github.json");
protected static final String REDIRECT_URL = "http://localhost:8000/auth_flow";
protected static final int SERVER_LISTENING_PORT = 8000;

@Override
protected Path get_credentials_path() {
return CREDENTIALS_PATH;
}

@Override
protected OAuthFlowImplementation getFlowObject(ConfigRepository configRepository) {
return new GithubOAuthFlow(configRepository);
}

@Override
protected int getServerListeningPort() {
return SERVER_LISTENING_PORT;
}

@BeforeEach
public void setup() throws IOException {
super.setup();
}

@Test
public void testFullGithubOAuthFlow() throws InterruptedException, ConfigNotFoundException, IOException, JsonValidationException {
int limit = 20;
final UUID workspaceId = UUID.randomUUID();
final UUID definitionId = UUID.randomUUID();
final String fullConfigAsString = new String(Files.readAllBytes(CREDENTIALS_PATH));
final JsonNode credentialsJson = Jsons.deserialize(fullConfigAsString);
when(configRepository.listSourceOAuthParam()).thenReturn(List.of(new SourceOAuthParameter()
.withOauthParameterId(UUID.randomUUID())
.withSourceDefinitionId(definitionId)
.withWorkspaceId(workspaceId)
.withConfiguration(Jsons.jsonNode(ImmutableMap.builder()
.put("client_id", credentialsJson.get("client_id").asText())
.put("client_secret", credentialsJson.get("client_secret").asText())
.build()))));
final String url = flow.getSourceConsentUrl(workspaceId, definitionId, REDIRECT_URL);
LOGGER.info("Waiting for user consent at: {}", url);
// TODO: To automate, start a selenium job to navigate to the Consent URL and click on allowing
// access...
while (!serverHandler.isSucceeded() && limit > 0) {
Thread.sleep(1000);
limit -= 1;
}
assertTrue(serverHandler.isSucceeded(), "Failed to get User consent on time");
final Map<String, Object> params = flow.completeSourceOAuth(workspaceId, definitionId,
Map.of("code", serverHandler.getParamValue()), REDIRECT_URL);
LOGGER.info("Response from completing OAuth Flow is: {}", params.toString());
assertTrue(params.containsKey("access_token"));
assertTrue(params.get("access_token").toString().length() > 0);
}

}
Expand Up @@ -30,7 +30,8 @@ public abstract class OAuthFlowIntegrationTest {
* due to the consent flow in the browser
*/
protected static final Logger LOGGER = LoggerFactory.getLogger(OAuthFlowIntegrationTest.class);
protected static final String REDIRECT_URL = "http://localhost/code";
protected static final String REDIRECT_URL = "http://localhost/auth_flow";
protected static final int SERVER_LISTENING_PORT = 80;

protected ConfigRepository configRepository;
protected OAuthFlowImplementation flow;
Expand All @@ -51,14 +52,20 @@ public void setup() throws IOException {

flow = this.getFlowObject(configRepository);

server = HttpServer.create(new InetSocketAddress(80), 0);
System.out.println(getServerListeningPort());
server = HttpServer.create(new InetSocketAddress(getServerListeningPort()), 0);
server.setExecutor(null); // creates a default executor
server.start();
serverHandler = new ServerHandler("code");
server.createContext("/code", serverHandler);
// Same endpoint as we use for airbyte instance
server.createContext("/auth_flow", serverHandler);

}

protected int getServerListeningPort() {
return SERVER_LISTENING_PORT;
}

@AfterEach
void tearDown() {
server.stop(1);
Expand Down
@@ -0,0 +1,82 @@
/*
* Copyright (c) 2021 Airbyte, Inc., all rights reserved.
*/

package io.airbyte.oauth.flows;

import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when;

import com.google.common.collect.ImmutableMap;
import io.airbyte.commons.json.Jsons;
import io.airbyte.config.SourceOAuthParameter;
import io.airbyte.config.persistence.ConfigNotFoundException;
import io.airbyte.config.persistence.ConfigRepository;
import io.airbyte.validation.json.JsonValidationException;
import java.io.IOException;
import java.net.http.HttpClient;
import java.net.http.HttpResponse;
import java.util.List;
import java.util.Map;
import java.util.UUID;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;

public class GithubOAuthFlowTest {

private UUID workspaceId;
private UUID definitionId;
private ConfigRepository configRepository;
private GithubOAuthFlow githuboAuthFlow;
private HttpClient httpClient;

private static final String REDIRECT_URL = "https://airbyte.io";

private static String getConstantState() {
return "state";
}

@BeforeEach
public void setup() throws IOException, JsonValidationException {
workspaceId = UUID.randomUUID();
definitionId = UUID.randomUUID();
configRepository = mock(ConfigRepository.class);
httpClient = mock(HttpClient.class);
when(configRepository.listSourceOAuthParam()).thenReturn(List.of(new SourceOAuthParameter()
.withOauthParameterId(UUID.randomUUID())
.withSourceDefinitionId(definitionId)
.withWorkspaceId(workspaceId)
.withConfiguration(Jsons.jsonNode(
Map.of("credentials",
ImmutableMap.builder()
.put("client_id", "test_client_id")
.put("client_secret", "test_client_secret")
.build())))));
githuboAuthFlow = new GithubOAuthFlow(configRepository, httpClient, GithubOAuthFlowTest::getConstantState);

}

@Test
public void testGetSourceConcentUrl() throws IOException, InterruptedException, ConfigNotFoundException {
final String concentUrl =
githuboAuthFlow.getSourceConsentUrl(workspaceId, definitionId, REDIRECT_URL);
assertEquals(concentUrl,
"https://github.com/login/oauth/authorize?client_id=test_client_id&redirect_uri=https%3A%2F%2Fairbyte.io&state=state");
}

@Test
public void testCompleteSourceOAuth() throws IOException, JsonValidationException, InterruptedException, ConfigNotFoundException {

Map<String, String> returnedCredentials = Map.of("access_token", "refresh_token_response");
final HttpResponse response = mock(HttpResponse.class);
when(response.body()).thenReturn(Jsons.serialize(returnedCredentials));
when(httpClient.send(any(), any())).thenReturn(response);
final Map<String, Object> queryParams = Map.of("code", "test_code");
final Map<String, Object> actualQueryParams =
githuboAuthFlow.completeSourceOAuth(workspaceId, definitionId, queryParams, REDIRECT_URL);
assertEquals(Jsons.serialize(Map.of("credentials", returnedCredentials)), Jsons.serialize(actualQueryParams));
}

}

0 comments on commit b230bc5

Please sign in to comment.