Skip to content

Commit b230bc5

Browse files
author
Dmytro
authored
Github oauth backend. (#7237)
1 parent a35f93f commit b230bc5

File tree

10 files changed

+267
-11
lines changed

10 files changed

+267
-11
lines changed

airbyte-oauth/src/main/java/io/airbyte/oauth/BaseOAuthFlow.java

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -175,12 +175,13 @@ private Map<String, Object> completeOAuthFlow(final String clientId,
175175
final String redirectUrl,
176176
JsonNode oAuthParamConfig)
177177
throws IOException {
178-
var accessTokenUrl = getAccessTokenUrl(oAuthParamConfig);
178+
var accessTokenUrl = getAccessTokenUrl();
179179
final HttpRequest request = HttpRequest.newBuilder()
180180
.POST(HttpRequest.BodyPublishers
181181
.ofString(tokenReqContentType.converter.apply(getAccessTokenQueryParameters(clientId, clientSecret, authCode, redirectUrl))))
182182
.uri(URI.create(accessTokenUrl))
183183
.header("Content-Type", tokenReqContentType.contentType)
184+
.header("Accept", "application/json")
184185
.build();
185186
// TODO: Handle error response to report better messages
186187
try {
@@ -220,7 +221,7 @@ protected String extractCodeParameter(Map<String, Object> queryParams) throws IO
220221
/**
221222
* Returns the URL where to retrieve the access token from.
222223
*/
223-
protected abstract String getAccessTokenUrl(JsonNode oAuthParamConfig);
224+
protected abstract String getAccessTokenUrl();
224225

225226
protected Map<String, Object> extractRefreshToken(final JsonNode data, String accessTokenUrl) throws IOException {
226227
final Map<String, Object> result = new HashMap<>();

airbyte-oauth/src/main/java/io/airbyte/oauth/OAuthImplementationFactory.java

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
import io.airbyte.config.persistence.ConfigRepository;
99
import io.airbyte.oauth.flows.AsanaOAuthFlow;
1010
import io.airbyte.oauth.flows.FacebookMarketingOAuthFlow;
11+
import io.airbyte.oauth.flows.GithubOAuthFlow;
1112
import io.airbyte.oauth.flows.SalesforceOAuthFlow;
1213
import io.airbyte.oauth.flows.TrelloOAuthFlow;
1314
import io.airbyte.oauth.flows.google.GoogleAdsOAuthFlow;
@@ -24,6 +25,7 @@ public OAuthImplementationFactory(final ConfigRepository configRepository) {
2425
OAUTH_FLOW_MAPPING = ImmutableMap.<String, OAuthFlowImplementation>builder()
2526
.put("airbyte/source-asana", new AsanaOAuthFlow(configRepository))
2627
.put("airbyte/source-facebook-marketing", new FacebookMarketingOAuthFlow(configRepository))
28+
.put("airbyte/source-github", new GithubOAuthFlow(configRepository))
2729
.put("airbyte/source-google-ads", new GoogleAdsOAuthFlow(configRepository))
2830
.put("airbyte/source-google-analytics-v4", new GoogleAnalyticsOAuthFlow(configRepository))
2931
.put("airbyte/source-google-search-console", new GoogleSearchConsoleOAuthFlow(configRepository))

airbyte-oauth/src/main/java/io/airbyte/oauth/flows/AsanaOAuthFlow.java

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,6 @@
44

55
package io.airbyte.oauth.flows;
66

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

5150
@Override
52-
protected String getAccessTokenUrl(JsonNode oAuthParamConfig) {
51+
protected String getAccessTokenUrl() {
5352
return ACCESS_TOKEN_URL;
5453
}
5554

airbyte-oauth/src/main/java/io/airbyte/oauth/flows/FacebookMarketingOAuthFlow.java

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -54,7 +54,7 @@ protected String formatConsentUrl(final UUID definitionId, final String clientId
5454
}
5555

5656
@Override
57-
protected String getAccessTokenUrl(JsonNode oAuthParamConfig) {
57+
protected String getAccessTokenUrl() {
5858
return ACCESS_TOKEN_URL;
5959
}
6060

Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
1+
/*
2+
* Copyright (c) 2021 Airbyte, Inc., all rights reserved.
3+
*/
4+
5+
package io.airbyte.oauth.flows;
6+
7+
import com.fasterxml.jackson.databind.JsonNode;
8+
import com.google.common.annotations.VisibleForTesting;
9+
import com.google.common.base.Preconditions;
10+
import io.airbyte.config.persistence.ConfigRepository;
11+
import io.airbyte.oauth.BaseOAuthFlow;
12+
import java.io.IOException;
13+
import java.net.URISyntaxException;
14+
import java.net.http.HttpClient;
15+
import java.util.Map;
16+
import java.util.UUID;
17+
import java.util.function.Supplier;
18+
import org.apache.http.client.utils.URIBuilder;
19+
20+
/**
21+
* Following docs from
22+
* https://docs.github.com/en/developers/apps/building-oauth-apps/authorizing-oauth-apps#web-application-flow
23+
*/
24+
public class GithubOAuthFlow extends BaseOAuthFlow {
25+
26+
private static final String AUTHORIZE_URL = "https://github.com/login/oauth/authorize";
27+
private static final String ACCESS_TOKEN_URL = "https://github.com/login/oauth/access_token";
28+
29+
public GithubOAuthFlow(final ConfigRepository configRepository) {
30+
super(configRepository);
31+
}
32+
33+
@VisibleForTesting
34+
GithubOAuthFlow(final ConfigRepository configRepository, final HttpClient httpClient, final Supplier<String> stateSupplier) {
35+
super(configRepository, httpClient, stateSupplier);
36+
}
37+
38+
@Override
39+
protected String formatConsentUrl(final UUID definitionId, final String clientId, final String redirectUrl) throws IOException {
40+
try {
41+
// No scope means read-only access to public information
42+
// https://docs.github.com/en/developers/apps/building-oauth-apps/scopes-for-oauth-apps#available-scopes
43+
return new URIBuilder(AUTHORIZE_URL)
44+
.addParameter("client_id", clientId)
45+
.addParameter("redirect_uri", redirectUrl)
46+
.addParameter("state", getState())
47+
.build().toString();
48+
} catch (URISyntaxException e) {
49+
throw new IOException("Failed to format Consent URL for OAuth flow", e);
50+
}
51+
}
52+
53+
@Override
54+
protected String getAccessTokenUrl() {
55+
return ACCESS_TOKEN_URL;
56+
}
57+
58+
@Override
59+
protected Map<String, Object> extractRefreshToken(final JsonNode data, String accessTokenUrl) throws IOException {
60+
System.out.println(data);
61+
if (data.has("access_token")) {
62+
return Map.of("credentials", Map.of("access_token", data.get("access_token").asText()));
63+
} else {
64+
throw new IOException(String.format("Missing 'access_token' in query params from %s", ACCESS_TOKEN_URL));
65+
}
66+
}
67+
68+
@Override
69+
protected String getClientIdUnsafe(final JsonNode config) {
70+
// the config object containing client ID and secret is nested inside the "credentials" object
71+
Preconditions.checkArgument(config.hasNonNull("credentials"));
72+
return super.getClientIdUnsafe(config.get("credentials"));
73+
}
74+
75+
@Override
76+
protected String getClientSecretUnsafe(final JsonNode config) {
77+
// the config object containing client ID and secret is nested inside the "credentials" object
78+
Preconditions.checkArgument(config.hasNonNull("credentials"));
79+
return super.getClientSecretUnsafe(config.get("credentials"));
80+
}
81+
82+
}

airbyte-oauth/src/main/java/io/airbyte/oauth/flows/SalesforceOAuthFlow.java

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -51,7 +51,7 @@ protected String formatConsentUrl(UUID definitionId, String clientId, String red
5151
}
5252

5353
@Override
54-
protected String getAccessTokenUrl(JsonNode oAuthConfig) {
54+
protected String getAccessTokenUrl() {
5555
return ACCESS_TOKEN_URL;
5656
}
5757

airbyte-oauth/src/main/java/io/airbyte/oauth/flows/google/GoogleOAuthFlow.java

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,6 @@
44

55
package io.airbyte.oauth.flows.google;
66

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

6564
@Override
66-
protected String getAccessTokenUrl(JsonNode oAuthParamConfig) {
65+
protected String getAccessTokenUrl() {
6766
return ACCESS_TOKEN_URL;
6867
}
6968

Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
1+
/*
2+
* Copyright (c) 2021 Airbyte, Inc., all rights reserved.
3+
*/
4+
5+
package io.airbyte.oauth.flows;
6+
7+
import static org.junit.jupiter.api.Assertions.assertTrue;
8+
import static org.mockito.Mockito.when;
9+
10+
import com.fasterxml.jackson.databind.JsonNode;
11+
import com.google.common.collect.ImmutableMap;
12+
import io.airbyte.commons.json.Jsons;
13+
import io.airbyte.config.SourceOAuthParameter;
14+
import io.airbyte.config.persistence.ConfigNotFoundException;
15+
import io.airbyte.config.persistence.ConfigRepository;
16+
import io.airbyte.oauth.OAuthFlowImplementation;
17+
import io.airbyte.validation.json.JsonValidationException;
18+
import java.io.IOException;
19+
import java.nio.file.Files;
20+
import java.nio.file.Path;
21+
import java.util.List;
22+
import java.util.Map;
23+
import java.util.UUID;
24+
import org.junit.jupiter.api.BeforeEach;
25+
import org.junit.jupiter.api.Test;
26+
27+
public class GithubOAuthFlowIntegrationTest extends OAuthFlowIntegrationTest {
28+
29+
protected static final Path CREDENTIALS_PATH = Path.of("secrets/github.json");
30+
protected static final String REDIRECT_URL = "http://localhost:8000/auth_flow";
31+
protected static final int SERVER_LISTENING_PORT = 8000;
32+
33+
@Override
34+
protected Path get_credentials_path() {
35+
return CREDENTIALS_PATH;
36+
}
37+
38+
@Override
39+
protected OAuthFlowImplementation getFlowObject(ConfigRepository configRepository) {
40+
return new GithubOAuthFlow(configRepository);
41+
}
42+
43+
@Override
44+
protected int getServerListeningPort() {
45+
return SERVER_LISTENING_PORT;
46+
}
47+
48+
@BeforeEach
49+
public void setup() throws IOException {
50+
super.setup();
51+
}
52+
53+
@Test
54+
public void testFullGithubOAuthFlow() throws InterruptedException, ConfigNotFoundException, IOException, JsonValidationException {
55+
int limit = 20;
56+
final UUID workspaceId = UUID.randomUUID();
57+
final UUID definitionId = UUID.randomUUID();
58+
final String fullConfigAsString = new String(Files.readAllBytes(CREDENTIALS_PATH));
59+
final JsonNode credentialsJson = Jsons.deserialize(fullConfigAsString);
60+
when(configRepository.listSourceOAuthParam()).thenReturn(List.of(new SourceOAuthParameter()
61+
.withOauthParameterId(UUID.randomUUID())
62+
.withSourceDefinitionId(definitionId)
63+
.withWorkspaceId(workspaceId)
64+
.withConfiguration(Jsons.jsonNode(ImmutableMap.builder()
65+
.put("client_id", credentialsJson.get("client_id").asText())
66+
.put("client_secret", credentialsJson.get("client_secret").asText())
67+
.build()))));
68+
final String url = flow.getSourceConsentUrl(workspaceId, definitionId, REDIRECT_URL);
69+
LOGGER.info("Waiting for user consent at: {}", url);
70+
// TODO: To automate, start a selenium job to navigate to the Consent URL and click on allowing
71+
// access...
72+
while (!serverHandler.isSucceeded() && limit > 0) {
73+
Thread.sleep(1000);
74+
limit -= 1;
75+
}
76+
assertTrue(serverHandler.isSucceeded(), "Failed to get User consent on time");
77+
final Map<String, Object> params = flow.completeSourceOAuth(workspaceId, definitionId,
78+
Map.of("code", serverHandler.getParamValue()), REDIRECT_URL);
79+
LOGGER.info("Response from completing OAuth Flow is: {}", params.toString());
80+
assertTrue(params.containsKey("access_token"));
81+
assertTrue(params.get("access_token").toString().length() > 0);
82+
}
83+
84+
}

airbyte-oauth/src/test-integration/java/io/airbyte/oauth/flows/OAuthFlowIntegrationTest.java

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,8 @@ public abstract class OAuthFlowIntegrationTest {
3030
* due to the consent flow in the browser
3131
*/
3232
protected static final Logger LOGGER = LoggerFactory.getLogger(OAuthFlowIntegrationTest.class);
33-
protected static final String REDIRECT_URL = "http://localhost/code";
33+
protected static final String REDIRECT_URL = "http://localhost/auth_flow";
34+
protected static final int SERVER_LISTENING_PORT = 80;
3435

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

5253
flow = this.getFlowObject(configRepository);
5354

54-
server = HttpServer.create(new InetSocketAddress(80), 0);
55+
System.out.println(getServerListeningPort());
56+
server = HttpServer.create(new InetSocketAddress(getServerListeningPort()), 0);
5557
server.setExecutor(null); // creates a default executor
5658
server.start();
5759
serverHandler = new ServerHandler("code");
58-
server.createContext("/code", serverHandler);
60+
// Same endpoint as we use for airbyte instance
61+
server.createContext("/auth_flow", serverHandler);
5962

6063
}
6164

65+
protected int getServerListeningPort() {
66+
return SERVER_LISTENING_PORT;
67+
}
68+
6269
@AfterEach
6370
void tearDown() {
6471
server.stop(1);
Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
1+
/*
2+
* Copyright (c) 2021 Airbyte, Inc., all rights reserved.
3+
*/
4+
5+
package io.airbyte.oauth.flows;
6+
7+
import static org.junit.jupiter.api.Assertions.assertEquals;
8+
import static org.mockito.ArgumentMatchers.any;
9+
import static org.mockito.Mockito.mock;
10+
import static org.mockito.Mockito.when;
11+
12+
import com.google.common.collect.ImmutableMap;
13+
import io.airbyte.commons.json.Jsons;
14+
import io.airbyte.config.SourceOAuthParameter;
15+
import io.airbyte.config.persistence.ConfigNotFoundException;
16+
import io.airbyte.config.persistence.ConfigRepository;
17+
import io.airbyte.validation.json.JsonValidationException;
18+
import java.io.IOException;
19+
import java.net.http.HttpClient;
20+
import java.net.http.HttpResponse;
21+
import java.util.List;
22+
import java.util.Map;
23+
import java.util.UUID;
24+
import org.junit.jupiter.api.BeforeEach;
25+
import org.junit.jupiter.api.Test;
26+
27+
public class GithubOAuthFlowTest {
28+
29+
private UUID workspaceId;
30+
private UUID definitionId;
31+
private ConfigRepository configRepository;
32+
private GithubOAuthFlow githuboAuthFlow;
33+
private HttpClient httpClient;
34+
35+
private static final String REDIRECT_URL = "https://airbyte.io";
36+
37+
private static String getConstantState() {
38+
return "state";
39+
}
40+
41+
@BeforeEach
42+
public void setup() throws IOException, JsonValidationException {
43+
workspaceId = UUID.randomUUID();
44+
definitionId = UUID.randomUUID();
45+
configRepository = mock(ConfigRepository.class);
46+
httpClient = mock(HttpClient.class);
47+
when(configRepository.listSourceOAuthParam()).thenReturn(List.of(new SourceOAuthParameter()
48+
.withOauthParameterId(UUID.randomUUID())
49+
.withSourceDefinitionId(definitionId)
50+
.withWorkspaceId(workspaceId)
51+
.withConfiguration(Jsons.jsonNode(
52+
Map.of("credentials",
53+
ImmutableMap.builder()
54+
.put("client_id", "test_client_id")
55+
.put("client_secret", "test_client_secret")
56+
.build())))));
57+
githuboAuthFlow = new GithubOAuthFlow(configRepository, httpClient, GithubOAuthFlowTest::getConstantState);
58+
59+
}
60+
61+
@Test
62+
public void testGetSourceConcentUrl() throws IOException, InterruptedException, ConfigNotFoundException {
63+
final String concentUrl =
64+
githuboAuthFlow.getSourceConsentUrl(workspaceId, definitionId, REDIRECT_URL);
65+
assertEquals(concentUrl,
66+
"https://github.com/login/oauth/authorize?client_id=test_client_id&redirect_uri=https%3A%2F%2Fairbyte.io&state=state");
67+
}
68+
69+
@Test
70+
public void testCompleteSourceOAuth() throws IOException, JsonValidationException, InterruptedException, ConfigNotFoundException {
71+
72+
Map<String, String> returnedCredentials = Map.of("access_token", "refresh_token_response");
73+
final HttpResponse response = mock(HttpResponse.class);
74+
when(response.body()).thenReturn(Jsons.serialize(returnedCredentials));
75+
when(httpClient.send(any(), any())).thenReturn(response);
76+
final Map<String, Object> queryParams = Map.of("code", "test_code");
77+
final Map<String, Object> actualQueryParams =
78+
githuboAuthFlow.completeSourceOAuth(workspaceId, definitionId, queryParams, REDIRECT_URL);
79+
assertEquals(Jsons.serialize(Map.of("credentials", returnedCredentials)), Jsons.serialize(actualQueryParams));
80+
}
81+
82+
}

0 commit comments

Comments
 (0)