Skip to content

Commit

Permalink
feat(PluggyClient): add credentials encryption for POST/PATCH to /ite…
Browse files Browse the repository at this point in the history
…ms and add rsaPublicKey method. (#45)

* refactor: clean up unused code.

* chore(GetAccountsTest): remove unneeded dependency.

* feat(PluggyClient)

* chore: bump minor version.

* fix(EncryptedParametersInterceptor): re-throw error.
  • Loading branch information
NicolasMontone authored Jul 11, 2023
1 parent 5e4f386 commit 8bde7be
Show file tree
Hide file tree
Showing 15 changed files with 172 additions and 42 deletions.
2 changes: 1 addition & 1 deletion pom.xml
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@

<groupId>ai.pluggy</groupId>
<artifactId>pluggy-java</artifactId>
<version>0.15.1</version>
<version>0.16.0</version>

<packaging>jar</packaging>

Expand Down
1 change: 0 additions & 1 deletion src/main/java/ai/pluggy/client/PluggyApiService.java
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,6 @@
import retrofit2.http.QueryMap;

public interface PluggyApiService {

@GET("/connectors")
Call<ConnectorsResponse> getConnectors();

Expand Down
16 changes: 12 additions & 4 deletions src/main/java/ai/pluggy/client/PluggyClient.java
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@

import ai.pluggy.client.auth.ApiKeyAuthInterceptor;
import ai.pluggy.client.auth.AuthenticationHelper;
import ai.pluggy.client.auth.EncryptedParametersInterceptor;
import ai.pluggy.client.response.ErrorResponse;
import ai.pluggy.exception.PluggyException;
import ai.pluggy.utils.Utils;
Expand All @@ -28,14 +29,11 @@
import retrofit2.converter.gson.GsonConverterFactory;

public final class PluggyClient {

public static final int ONE_MIB_BYTES = 1024 * 1024;
public static String AUTH_URL_PATH = "/auth";
private String baseUrl;

private String clientId;
private String clientSecret;

private OkHttpClient httpClient;
private PluggyApiService service;

Expand Down Expand Up @@ -156,6 +154,7 @@ public static class PluggyClientBuilder {
private String clientId;
private String clientSecret;
private String baseUrl;
private String rsaPublicKey;
private Builder okHttpClientBuilder;
private boolean disableDefaultAuthInterceptor = false;

Expand All @@ -180,6 +179,11 @@ public PluggyClientBuilder baseUrl(String baseUrl) {
return this;
}

public PluggyClientBuilder rsaPublicKey(String rsaPublicKey) {
this.rsaPublicKey = rsaPublicKey;
return this;
}

/**
* Opt-out from provided default ApiKeyAuthInterceptor, which takes care of apiKey
* authorization, by requesting a new apiKey token when it's not set, or by reactively
Expand Down Expand Up @@ -211,6 +215,10 @@ private OkHttpClient buildOkHttpClient(String baseUrl) {
.addInterceptor(new ApiKeyAuthInterceptor(authUrlPath, clientId, clientSecret));
}

if (this.rsaPublicKey != null) {
this.okHttpClientBuilder.addInterceptor(new EncryptedParametersInterceptor(this.rsaPublicKey));
}

return okHttpClientBuilder.build();
}

Expand Down Expand Up @@ -278,7 +286,7 @@ public String authenticate() throws IOException, PluggyException {
.post(body)
.addHeader("content-type", "application/json")
.addHeader("cache-control", "no-cache")
.addHeader("User-Agent", "PluggyJava/0.15.1")
.addHeader("User-Agent", "PluggyJava/0.16.0")
.build();

String apiKey;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,6 @@

import static ai.pluggy.utils.Asserts.assertNotNull;

import ai.pluggy.utils.Utils;
import com.google.gson.Gson;

import java.io.IOException;
Expand Down Expand Up @@ -116,7 +115,7 @@ private Request requestWithAuth(Request originalRequest, String apiKey) {
return originalRequest.newBuilder()
.header(X_API_KEY_HEADER, apiKey)
// TOOD: add dynamic version
.header("User-Agent", "PluggyJava/0.15.1")
.header("User-Agent", "PluggyJava/0.16.0")
.build();
}

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
package ai.pluggy.client.auth;

import static ai.pluggy.utils.Asserts.assertNotNull;

import java.io.IOException;
import java.security.InvalidKeyException;
import java.security.KeyFactory;
import java.security.NoSuchAlgorithmException;
import java.security.PublicKey;
import java.security.spec.InvalidKeySpecException;
import java.security.spec.X509EncodedKeySpec;

import okio.Buffer;
import java.util.Arrays;
import java.util.Base64;

import javax.crypto.BadPaddingException;
import javax.crypto.Cipher;
import javax.crypto.IllegalBlockSizeException;
import javax.crypto.NoSuchPaddingException;

import org.jetbrains.annotations.NotNull;

import com.google.gson.Gson;
import com.google.gson.JsonObject;

import okhttp3.Interceptor;
import okhttp3.Request;
import okhttp3.RequestBody;
import okhttp3.Response;

public class EncryptedParametersInterceptor implements Interceptor {
private String rsaPublicKey;
private String path = "/items";
private String[] methods = { "PATCH", "POST" };

public EncryptedParametersInterceptor(String rsaPublicKey) {
assertNotNull(rsaPublicKey, rsaPublicKey);
this.rsaPublicKey = rsaPublicKey;
}

public Response intercept(@NotNull Chain chain) throws IOException {
Request originalRequest = chain.request();
String method = originalRequest.method();
RequestBody originalBody = originalRequest.body();

if (originalBody == null) {
return chain.proceed(originalRequest);
}

JsonObject jsonBody = this.transformBodyToJsonObject(originalBody);

if (!Arrays.asList(methods).contains(method)
|| !originalRequest.url().encodedPath().contains(path) && !jsonBody.has("parameters")) {
return chain.proceed(originalRequest);
}

String parameters = jsonBody.get("parameters").toString();

String encryptedParameters = encryptParameters(parameters);

jsonBody.addProperty("parameters", encryptedParameters);

// create new request with new body and same headers/params
Request newRequest = originalRequest.newBuilder()
.method(method, RequestBody.create(jsonBody.toString(), originalBody.contentType()))
.build();

return chain.proceed(newRequest);
}

private String encryptParameters(String parameters) throws IOException {
String publicKeyPEM = this.rsaPublicKey
.replace("-----BEGIN PUBLIC KEY-----", "")
.replace("-----END PUBLIC KEY-----", "")
.replaceAll("\\s+", "");

byte[] publicKeyBytes = Base64.getDecoder().decode(publicKeyPEM);
X509EncodedKeySpec keySpec = new X509EncodedKeySpec(publicKeyBytes);

try {
KeyFactory keyFactory = KeyFactory.getInstance("RSA");
PublicKey publicKey = keyFactory.generatePublic(keySpec);
Cipher cipher = Cipher.getInstance("RSA/ECB/OAEPPadding");
cipher.init(Cipher.ENCRYPT_MODE, publicKey);
byte[] encryptedPayloadBytes = cipher.doFinal(parameters.getBytes());
String encryptedPayload = Base64.getEncoder().encodeToString(encryptedPayloadBytes);
return encryptedPayload;

} catch (NoSuchAlgorithmException | InvalidKeySpecException | NoSuchPaddingException | InvalidKeyException
| BadPaddingException | IllegalBlockSizeException error) {
throw new IOException("Error encrypting parameters", error);
}
}

private JsonObject transformBodyToJsonObject(RequestBody body) throws IOException {
Buffer buffer = new Buffer();
try {
body.writeTo(buffer);
} catch (IOException error) {
throw error;
}
String bodyString = buffer.readUtf8();

JsonObject jsonBody = new Gson().fromJson(bodyString, JsonObject.class);

return jsonBody;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,6 @@
@Value
@AllArgsConstructor
public class CreateItemRequest {

Integer connectorId;
ParametersMap parameters;
String webhookUrl;
Expand Down
3 changes: 0 additions & 3 deletions src/main/java/ai/pluggy/client/response/Account.java
Original file line number Diff line number Diff line change
@@ -1,8 +1,5 @@
package ai.pluggy.client.response;


import java.util.ArrayList;
import java.util.List;
import lombok.Data;

@Data
Expand Down
2 changes: 0 additions & 2 deletions src/main/java/ai/pluggy/client/response/WebhookEventType.java
Original file line number Diff line number Diff line change
@@ -1,8 +1,6 @@
package ai.pluggy.client.response;

import com.google.gson.annotations.SerializedName;
import lombok.AllArgsConstructor;
import lombok.Getter;

public enum WebhookEventType {
@SerializedName("item/created")
Expand Down
1 change: 0 additions & 1 deletion src/main/java/ai/pluggy/exception/PluggyException.java
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
package ai.pluggy.exception;

import java.io.IOException;
import okhttp3.Response;

public class PluggyException extends Exception {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,13 +11,13 @@
import org.junit.platform.commons.util.StringUtils;

class BaseApiIntegrationTest {

static final String CLIENT_ID = System.getenv("PLUGGY_CLIENT_ID");
static final String CLIENT_SECRET = System.getenv("PLUGGY_CLIENT_SECRET");
static final String TEST_BASE_URL = System.getenv("PLUGGY_BASE_URL");
static final String RSA_PUBLIC_KEY = System.getenv("PLUGGY_RSA_PUBLIC_KEY");

PluggyClient client;

protected List<String> itemsIdCreated = new ArrayList<>();

public List<String> getItemsIdCreated() {
Expand All @@ -28,9 +28,9 @@ public List<String> getItemsIdCreated() {
void setUp() {
checkEnvErrors();
client = PluggyClient.builder()
.baseUrl(TEST_BASE_URL)
.clientIdAndSecret(CLIENT_ID, CLIENT_SECRET)
.build();
.baseUrl(TEST_BASE_URL)
.clientIdAndSecret(CLIENT_ID, CLIENT_SECRET)
.build();
}

protected void checkEnvErrors() {
Expand All @@ -46,12 +46,12 @@ protected void checkEnvErrors() {
}
if (missingEnvVars.size() > 0) {
String envVarsListString = missingEnvVars.stream()
.map(varName -> "'" + varName + "'")
.collect(Collectors.joining(", "));
.map(varName -> "'" + varName + "'")
.collect(Collectors.joining(", "));
throw new IllegalStateException("Must define " + envVarsListString + " env var(s)!");
}
}

@SneakyThrows
@AfterEach
protected void clearItems() {
Expand Down
53 changes: 41 additions & 12 deletions src/test/java/ai/pluggy/client/integration/CreateItemTest.java
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
import static org.junit.jupiter.api.Assertions.assertNull;
import static org.junit.jupiter.api.Assertions.assertTrue;

import ai.pluggy.client.PluggyClient;
import ai.pluggy.client.request.CreateItemRequest;
import ai.pluggy.client.request.ParametersMap;
import ai.pluggy.client.response.ErrorResponse;
Expand All @@ -17,13 +18,12 @@
import retrofit2.Response;

public class CreateItemTest extends BaseApiIntegrationTest {

@SneakyThrows
@Test
void createItem_withValidParams_responseOk() {
// create item params
ParametersMap parametersMap = ParametersMap.map("user", "user-ok")
.with("password", "password-ok");
.with("password", "password-ok");
Integer connectorId = 0;

// run request with 'connectorId', 'parameters' params
Expand All @@ -37,20 +37,21 @@ void createItem_withValidParams_responseOk() {
assertNotNull(itemResponse1);
assertEquals(itemResponse1.getConnector().getId(), connectorId);

// run request with 'connectorId', 'parameters', 'webhookUrl', 'clientUserId', params
// run request with 'connectorId', 'parameters', 'webhookUrl', 'clientUserId',
// params
String webhookUrl = "https://www.test.com/";
String clientUserId = "clientUserId";
CreateItemRequest createItemRequestWithWebhook = new CreateItemRequest(connectorId,
parametersMap, webhookUrl, clientUserId);
parametersMap, webhookUrl, clientUserId);
Response<ItemResponse> itemRequestWithWebhookResponse = client.service()
.createItem(createItemRequestWithWebhook).execute();
.createItem(createItemRequestWithWebhook).execute();
ItemResponse itemResponse2 = itemRequestWithWebhookResponse.body();

assertNotNull(itemResponse2);
assertEquals(itemResponse2.getConnector().getId(), connectorId);
assertEquals(itemResponse2.getClientUserId(), clientUserId);
assertEquals(itemResponse2.getWebhookUrl(), webhookUrl);

this.getItemsIdCreated().add(itemResponse1.getId());
this.getItemsIdCreated().add(itemResponse2.getId());
}
Expand All @@ -60,8 +61,8 @@ void createItem_withValidParams_responseOk() {
void createItem_withInvalidParams_responseError400() {
// create item params
ParametersMap parametersMap = ParametersMap
.map("bad-param-key", "asd")
.with("other-bad-param-key", "qwe");
.map("bad-param-key", "asd")
.with("other-bad-param-key", "qwe");
Integer connectorId = 0;

// run request with 'connectorId', 'parameters' params
Expand All @@ -81,9 +82,9 @@ void createItem_withInvalidParams_responseError400() {
// webhookUrl param 'localhost' is invalid, expect error 400
String localWebhookUrl = "localhost:9999";
CreateItemRequest createItemRequestWithLocalWebhook = new CreateItemRequest(connectorId,
parametersMap, localWebhookUrl);
parametersMap, localWebhookUrl);
Response<ItemResponse> itemRequestWithLocalWebhookResponse = client.service()
.createItem(createItemRequestWithLocalWebhook).execute();
.createItem(createItemRequestWithLocalWebhook).execute();
ErrorResponse localWebhookErrorResponse = client.parseError(itemRequestWithLocalWebhookResponse);
assertNotNull(localWebhookErrorResponse);
assertEquals(localWebhookErrorResponse.getCode(), 400);
Expand All @@ -92,12 +93,40 @@ void createItem_withInvalidParams_responseError400() {
// webhookUrl param http is invalid, expect error 400
String httpWebhookUrl = "http://www.test.com";
CreateItemRequest createItemRequestWithHttpWebhook = new CreateItemRequest(connectorId,
parametersMap, httpWebhookUrl);
parametersMap, httpWebhookUrl);
Response<ItemResponse> itemRequestWithHttpWebhookResponse = client.service()
.createItem(createItemRequestWithHttpWebhook).execute();
.createItem(createItemRequestWithHttpWebhook).execute();
ErrorResponse httpWebhookErrorResponse = client.parseError(itemRequestWithHttpWebhookResponse);
assertNotNull(httpWebhookErrorResponse);
assertEquals(httpWebhookErrorResponse.getCode(), 400);
assertNull(localWebhookErrorResponse.getDetails(), "should not include validation error details for webhookUrl");
}

@SneakyThrows
@Test
void createItem_withEncryptedParameters_responseOk() {
// create item params
ParametersMap parametersMap = ParametersMap.map("user", "user-ok")
.with("password", "password-ok");
Integer connectorId = 0;

// initialize client with RSA public key so the parameters are encrypted before
// sending to the API
PluggyClient clientWithEncryption = PluggyClient.builder()
.clientIdAndSecret(BaseApiIntegrationTest.CLIENT_ID, BaseApiIntegrationTest.CLIENT_SECRET)
.rsaPublicKey(BaseApiIntegrationTest.RSA_PUBLIC_KEY)
.build();

// run request with 'connectorId', 'parameters' params
CreateItemRequest createItemRequest = new CreateItemRequest(connectorId, parametersMap);
Call<ItemResponse> createItemRequestCall = clientWithEncryption.service().createItem(createItemRequest);
Response<ItemResponse> itemRequestResponse = createItemRequestCall.execute();
assertSuccessful(itemRequestResponse, clientWithEncryption);
ItemResponse itemResponse1 = itemRequestResponse.body();

assertNotNull(itemResponse1);
assertEquals(itemResponse1.getConnector().getId(), connectorId);

this.getItemsIdCreated().add(itemResponse1.getId());
}
}
Loading

0 comments on commit 8bde7be

Please sign in to comment.