Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

add HTTP plugin support for service account authentication #60

Open
wants to merge 1 commit into
base: develop
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 12 additions & 4 deletions pom.xml
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@
<name>HTTP Plugins</name>
<groupId>io.cdap</groupId>
<artifactId>http-plugins</artifactId>
<version>1.4.0-SNAPSHOT</version>
<version>1.4.1-service-account</version>
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please revert the version change heree. We'll bump the version on the release branch after cherrypicking your PR.

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

When we publish in the hub we would change this, right?


<licenses>
<license>
Expand Down Expand Up @@ -82,7 +82,7 @@
<gson.version>2.8.5</gson.version>
<hadoop.version>2.3.0</hadoop.version>
<httpcomponents.version>4.5.9</httpcomponents.version>
<hydrator.version>2.4.0-SNAPSHOT</hydrator.version>
<hydrator.version>2.3.0-SNAPSHOT</hydrator.version>

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why did we have to go to an older version?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why did we change this. Also, we probably shouldn't be depending on a snapshot version here.

<jackson.version>2.9.9</jackson.version>
<junit.version>4.11</junit.version>
<jython.version>2.7.1</jython.version>
Expand All @@ -93,6 +93,16 @@
</properties>

<dependencies>
<dependency>
<groupId>com.google.guava</groupId>
<artifactId>guava</artifactId>

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do we know if this could break any other plugins due to conflicting dependencies?

<version>13.0.1</version>
</dependency>
<dependency>
<groupId>com.google.auth</groupId>
<artifactId>google-auth-library-oauth2-http</artifactId>
<version>0.25.5</version>
</dependency>
<dependency>
<groupId>io.cdap.cdap</groupId>
<artifactId>cdap-api</artifactId>
Expand Down Expand Up @@ -354,8 +364,6 @@
<artifactId>jython-standalone</artifactId>
<version>${jython.version}</version>
</dependency>


<!-- tests -->
<dependency>
<groupId>io.cdap.cdap</groupId>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,9 @@ public abstract class BaseHttpSourceConfig extends ReferencePluginConfig {
public static final String PROPERTY_CLIENT_SECRET = "clientSecret";
public static final String PROPERTY_SCOPES = "scopes";
public static final String PROPERTY_REFRESH_TOKEN = "refreshToken";
public static final String PROPERTY_SERVICE_ACCOUNT_ENABLED = "serviceAccountEnabled";
public static final String PROPERTY_SERVICE_ACCOUNT_JSON = "serviceAccountJson";
public static final String PROPERTY_SERVICE_ACCOUNT_SCOPE = "serviceAccountScope";
public static final String PROPERTY_VERIFY_HTTPS = "verifyHttps";
public static final String PROPERTY_KEYSTORE_FILE = "keystoreFile";
public static final String PROPERTY_KEYSTORE_TYPE = "keystoreType";
Expand Down Expand Up @@ -316,6 +319,23 @@ public abstract class BaseHttpSourceConfig extends ReferencePluginConfig {
@Macro
protected String refreshToken;

@Name(PROPERTY_SERVICE_ACCOUNT_ENABLED)
@Description("If true, plugin will use service account key to perform oauth2 authentication.")
protected String serviceAccountEnabled;

@Nullable
@Name(PROPERTY_SERVICE_ACCOUNT_JSON)
@Description("Json key file content for OAuth2 with service account.")
@Macro
protected String serviceAccountJson;

@Nullable
@Name(PROPERTY_SERVICE_ACCOUNT_SCOPE)
@Description("Scope used when using a service account json key file. " +
"Defaults to https://www.googleapis.com/auth/cloud-platform")
@Macro
protected String serviceAccountScope;

@Name(PROPERTY_VERIFY_HTTPS)
@Description("If false, untrusted trust certificates (e.g. self signed), will not lead to an" +
"error. Do not disable this in production environment on a network you do not entirely trust. " +
Expand Down Expand Up @@ -533,6 +553,10 @@ public Boolean getOauth2Enabled() {
return Boolean.parseBoolean(oauth2Enabled);
}

public Boolean getServiceAccountEnabled() {
return Boolean.parseBoolean(serviceAccountEnabled);
}

@Nullable
public String getAuthUrl() {
return authUrl;
Expand Down Expand Up @@ -563,6 +587,16 @@ public String getRefreshToken() {
return refreshToken;
}

@Nullable
public String getServiceAccountJson() {
return serviceAccountJson;
}

@Nullable
public String getServiceAccountScope() {
return serviceAccountScope;
}

public Boolean getVerifyHttps() {
return Boolean.parseBoolean(verifyHttps);
}
Expand Down Expand Up @@ -794,6 +828,11 @@ PAGINATION_INDEX_PLACEHOLDER, getPaginationType()),
assertIsSet(getRefreshToken(), PROPERTY_REFRESH_TOKEN, reasonOauth2);
}

if (!containsMacro(PROPERTY_SERVICE_ACCOUNT_ENABLED) && this.getServiceAccountEnabled()) {
String reasonOauth2 = "Service Account is enabled";
assertIsSet(getServiceAccountJson(), PROPERTY_SERVICE_ACCOUNT_JSON, reasonOauth2);
}

if (!containsMacro(PROPERTY_VERIFY_HTTPS) && !getVerifyHttps()) {
assertIsNotSet(getTrustStoreFile(), PROPERTY_TRUSTSTORE_FILE,
String.format("trustore settings are ignored due to disabled %s", PROPERTY_VERIFY_HTTPS));
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -126,15 +126,20 @@ private CloseableHttpClient createHttpClient() throws IOException {

ArrayList<Header> clientHeaders = new ArrayList<>();

String accessToken = null;
// oAuth2
if (config.getServiceAccountEnabled()) {
accessToken = OAuthUtil.getAccessTokenByServiceAccount(HttpClients.createDefault(),
config.getServiceAccountJson(),
config.getServiceAccountScope());
}
if (config.getOauth2Enabled()) {
String accessToken = OAuthUtil.getAccessTokenByRefreshToken(HttpClients.createDefault(), config.getTokenUrl(),
config.getClientId(), config.getClientSecret(),
config.getRefreshToken());
clientHeaders.add(new BasicHeader("Authorization", "Bearer " + accessToken));
accessToken = OAuthUtil.getAccessTokenByRefreshToken(HttpClients.createDefault(), config.getTokenUrl(),
config.getClientId(), config.getClientSecret(),
config.getRefreshToken());
}


clientHeaders.add(new BasicHeader("Authorization", "Bearer " + accessToken));
// set default headers
if (headers != null) {
for (Map.Entry<String, String> headerEntry : this.headers.entrySet()) {
Expand Down
221 changes: 220 additions & 1 deletion src/main/java/io/cdap/plugin/http/source/common/http/OAuthUtil.java
Original file line number Diff line number Diff line change
Expand Up @@ -15,22 +15,176 @@
*/
package io.cdap.plugin.http.source.common.http;

import com.google.api.client.http.GenericUrl;
import com.google.api.client.http.HttpRequest;
import com.google.api.client.http.HttpRequestFactory;
import com.google.api.client.http.HttpResponse;
import com.google.api.client.http.UrlEncodedContent;
import com.google.api.client.http.javanet.NetHttpTransport;
import com.google.api.client.json.JsonFactory;
import com.google.api.client.json.JsonObjectParser;
import com.google.api.client.json.gson.GsonFactory;
import com.google.api.client.json.webtoken.JsonWebSignature;
import com.google.api.client.json.webtoken.JsonWebToken;
import com.google.api.client.util.GenericData;
import com.google.api.client.util.SecurityUtils;

import com.google.auth.http.HttpTransportFactory;
import com.google.auth.oauth2.GoogleCredentials;
import com.google.auth.oauth2.ServiceAccountCredentials;
import com.google.gson.JsonElement;
import com.google.gson.JsonObject;

import io.cdap.plugin.http.source.common.pagination.page.JSONUtil;

import org.apache.http.client.methods.CloseableHttpResponse;
import org.apache.http.client.methods.HttpPost;
import org.apache.http.client.utils.URIBuilder;
import org.apache.http.impl.client.CloseableHttpClient;
import org.apache.http.util.EntityUtils;


import java.io.BufferedReader;
import java.io.ByteArrayInputStream;
import java.io.IOException;
import java.io.InputStream;
import java.io.StringReader;
import java.io.StringWriter;
import java.net.URI;
import java.net.URISyntaxException;
import java.nio.charset.StandardCharsets;

import java.security.GeneralSecurityException;
import java.security.KeyFactory;
import java.security.PrivateKey;
import java.security.Signature;
import java.security.spec.PKCS8EncodedKeySpec;

import java.util.Arrays;
import java.util.Base64;
import java.util.Date;
import java.util.regex.Matcher;
import java.util.regex.Pattern;

/**
* A class which contains utilities to make OAuth2 specific calls.
*/
public class OAuthUtil {

public static PrivateKey readPKCS8Pem(String key) throws Exception {
key = key.replace("-----BEGIN PRIVATE KEY-----", "");
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please fix the indentation. See https://wiki.cask.co/display/CE/Coding+Standard

key = key.replace("-----END PRIVATE KEY-----", "");
key = key.replaceAll("\\s+", "");

// Base64 decode the result
byte [] pkcs8EncodedBytes = decode(key);

// extract the private key
PKCS8EncodedKeySpec keySpec = new PKCS8EncodedKeySpec(pkcs8EncodedBytes);
KeyFactory kf = KeyFactory.getInstance("RSA");
return kf.generatePrivate(keySpec);
}

public static String encodeBase64URLSafeString(byte[] binaryData) {
if (binaryData == null) {
return null;
}
return Base64.getUrlEncoder().withoutPadding().encodeToString(binaryData);
}

public static String signUsingRsaSha256(
PrivateKey privateKey,
JsonFactory jsonFactory,
JsonWebSignature.Header header,
JsonWebToken.Payload payload)
throws GeneralSecurityException, IOException {
String head = encodeBase64URLSafeString(jsonFactory.toByteArray(header));
String content = head + "."
+ encodeBase64URLSafeString(jsonFactory.toByteArray(payload));
byte[] contentBytes = content.getBytes();
Signature sig = Signature.getInstance("SHA256withRSA");
sig.initSign(privateKey);
sig.update(contentBytes);
byte[] signature = sig.sign();
return content + "." + encodeBase64URLSafeString(signature);
}



// copied from https://stackoverflow.com/a/4265472
private static char[] theALPHABET =
"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/".toCharArray();

private static int[] toInt = new int[128];

static {
for (int i = 0; i < theALPHABET.length; i++) {
toInt[theALPHABET[i]] = i;
}
}

/**
* Translates the specified byte array into Base64 string.
*
* @param buf the byte array (not null)
* @return the translated Base64 string (not null)
*/
public static String encode(byte[] buf) {
int size = buf.length;
char[] ar = new char[((size + 2) / 3) * 4];
int a = 0;
int i = 0;
while (i < size) {
byte b0 = buf[i++];
byte b1 = (i < size) ? buf[i++] : 0;
byte b2 = (i < size) ? buf[i++] : 0;

int mask = 0x3F;
ar[a++] = theALPHABET[(b0 >> 2) & mask];
ar[a++] = theALPHABET[((b0 << 4) | ((b1 & 0xFF) >> 4)) & mask];
ar[a++] = theALPHABET[((b1 << 2) | ((b2 & 0xFF) >> 6)) & mask];
ar[a++] = theALPHABET[b2 & mask];
}
switch (size % 3) {
case 1: ar[--a] = '=';
break;
case 2: ar[--a] = '=';
ar[--a] = '=';
break;
}
return new String(ar);
}

/**
* Translates the specified Base64 string into a byte array.
*
* @param s the Base64 string (not null)
* @return the byte array (not null)
*/
public static byte[] decode(String s) {
int delta = s.endsWith("==") ? 2 : s.endsWith("=") ? 1 : 0;
byte[] buffer = new byte[s.length() * 3 / 4 - delta];
int mask = 0xFF;
int index = 0;
for (int i = 0; i < s.length(); i += 4) {
int c0 = toInt[s.charAt(i)];
int c1 = toInt[s.charAt(i + 1)];
buffer[index++] = (byte) (((c0 << 2) | (c1 >> 4)) & mask);
if (index >= buffer.length) {
return buffer;
}
int c2 = toInt[s.charAt(i + 2)];
buffer[index++] = (byte) (((c1 << 4) | (c2 >> 2)) & mask);
if (index >= buffer.length) {
return buffer;
}
int c3 = toInt[s.charAt(i + 3)];
buffer[index++] = (byte) (((c2 << 6) | c3) & mask);
}
return buffer;
}


public static String getAccessTokenByRefreshToken(CloseableHttpClient httpclient, String tokenUrl, String clientId,
String clientSecret, String refreshToken)
throws IOException {
Expand All @@ -54,5 +208,70 @@ public static String getAccessTokenByRefreshToken(CloseableHttpClient httpclient
JsonElement jsonElement = JSONUtil.toJsonObject(responseString).get("access_token");
return jsonElement.getAsString();
}
}

public static String getAccessTokenByServiceAccount(CloseableHttpClient httpclient, String serviceAccountJson,
String serviceAccountScope)
throws IOException {
HttpTransportFactory transportFactory;
JsonObject sa = JSONUtil.toJsonObject(serviceAccountJson);
String tokenServerUri = sa.get("token_uri").getAsString();
String scope = serviceAccountScope;

if (serviceAccountScope == null) {
scope = "https://www.googleapis.com/auth/cloud-platform";
}

PrivateKey key;
try {
key = readPKCS8Pem(sa.get("private_key").getAsString());
} catch (Exception e) {
throw new IOException(
"Error decoding service account private key.", e);
}
long currentTime = System.currentTimeMillis();
JsonWebSignature.Header header = new JsonWebSignature.Header();
header.setAlgorithm("RS256");
header.setType("JWT");
header.setKeyId(sa.get("private_key_id").getAsString());


JsonWebToken.Payload payload = new JsonWebToken.Payload();
payload.setIssuer(sa.get("client_email").getAsString());
payload.setIssuedAtTimeSeconds(currentTime / 1000);
payload.setExpirationTimeSeconds(currentTime / 1000 + 3600); // one hour
payload.setSubject(sa.get("client_email").getAsString());
payload.put("scope", scope);
payload.setAudience(tokenServerUri);

String assertion;
try {
assertion = signUsingRsaSha256(key, GsonFactory.getDefaultInstance(), header, payload);
} catch (GeneralSecurityException e) {
throw new IOException(
"Error signing service account access token request with private key.", e);
}

GenericData tokenRequest = new GenericData();
tokenRequest.set("grant_type", "urn:ietf:params:oauth:grant-type:jwt-bearer");
tokenRequest.set("assertion", assertion);

UrlEncodedContent content = new UrlEncodedContent(tokenRequest);
HttpRequestFactory requestFactory = new NetHttpTransport().createRequestFactory();
HttpRequest request = requestFactory.buildPostRequest(new GenericUrl(tokenServerUri), content);
HttpResponse response = request.execute();

InputStream in = response.getContent();
StringWriter out = new StringWriter();
byte[] buf = new byte[4096];
int r;
while (true) {
r = in.read(buf);
if (r == -1) {
break;
}
out.write(new String(buf).substring(0, r));
}
JsonObject responseData = JSONUtil.toJsonObject(out.toString());
return responseData.get("access_token").toString();
}
}
Loading