diff --git a/pom.xml b/pom.xml index 5b44e61..7698688 100644 --- a/pom.xml +++ b/pom.xml @@ -5,7 +5,7 @@ ai.pluggy pluggy-java - 0.15.1 + 0.16.0 jar diff --git a/src/main/java/ai/pluggy/client/PluggyApiService.java b/src/main/java/ai/pluggy/client/PluggyApiService.java index 9e2d699..13dfae5 100644 --- a/src/main/java/ai/pluggy/client/PluggyApiService.java +++ b/src/main/java/ai/pluggy/client/PluggyApiService.java @@ -35,7 +35,6 @@ import retrofit2.http.QueryMap; public interface PluggyApiService { - @GET("/connectors") Call getConnectors(); diff --git a/src/main/java/ai/pluggy/client/PluggyClient.java b/src/main/java/ai/pluggy/client/PluggyClient.java index 1d46734..e363dff 100644 --- a/src/main/java/ai/pluggy/client/PluggyClient.java +++ b/src/main/java/ai/pluggy/client/PluggyClient.java @@ -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; @@ -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; @@ -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; @@ -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 @@ -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(); } @@ -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; diff --git a/src/main/java/ai/pluggy/client/auth/ApiKeyAuthInterceptor.java b/src/main/java/ai/pluggy/client/auth/ApiKeyAuthInterceptor.java index 75825a3..1e756e6 100644 --- a/src/main/java/ai/pluggy/client/auth/ApiKeyAuthInterceptor.java +++ b/src/main/java/ai/pluggy/client/auth/ApiKeyAuthInterceptor.java @@ -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; @@ -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(); } diff --git a/src/main/java/ai/pluggy/client/auth/EncryptedParametersInterceptor.java b/src/main/java/ai/pluggy/client/auth/EncryptedParametersInterceptor.java new file mode 100644 index 0000000..03a483c --- /dev/null +++ b/src/main/java/ai/pluggy/client/auth/EncryptedParametersInterceptor.java @@ -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; + } +} diff --git a/src/main/java/ai/pluggy/client/request/CreateItemRequest.java b/src/main/java/ai/pluggy/client/request/CreateItemRequest.java index 8645296..0180ed3 100644 --- a/src/main/java/ai/pluggy/client/request/CreateItemRequest.java +++ b/src/main/java/ai/pluggy/client/request/CreateItemRequest.java @@ -6,7 +6,6 @@ @Value @AllArgsConstructor public class CreateItemRequest { - Integer connectorId; ParametersMap parameters; String webhookUrl; diff --git a/src/main/java/ai/pluggy/client/response/Account.java b/src/main/java/ai/pluggy/client/response/Account.java index 6c209e7..708d4cd 100644 --- a/src/main/java/ai/pluggy/client/response/Account.java +++ b/src/main/java/ai/pluggy/client/response/Account.java @@ -1,8 +1,5 @@ package ai.pluggy.client.response; - -import java.util.ArrayList; -import java.util.List; import lombok.Data; @Data diff --git a/src/main/java/ai/pluggy/client/response/WebhookEventType.java b/src/main/java/ai/pluggy/client/response/WebhookEventType.java index 446d6ca..99c308e 100644 --- a/src/main/java/ai/pluggy/client/response/WebhookEventType.java +++ b/src/main/java/ai/pluggy/client/response/WebhookEventType.java @@ -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") diff --git a/src/main/java/ai/pluggy/exception/PluggyException.java b/src/main/java/ai/pluggy/exception/PluggyException.java index 0b64976..73b8c79 100644 --- a/src/main/java/ai/pluggy/exception/PluggyException.java +++ b/src/main/java/ai/pluggy/exception/PluggyException.java @@ -1,6 +1,5 @@ package ai.pluggy.exception; -import java.io.IOException; import okhttp3.Response; public class PluggyException extends Exception { diff --git a/src/test/java/ai/pluggy/client/integration/BaseApiIntegrationTest.java b/src/test/java/ai/pluggy/client/integration/BaseApiIntegrationTest.java index 03417ad..0bb55b4 100644 --- a/src/test/java/ai/pluggy/client/integration/BaseApiIntegrationTest.java +++ b/src/test/java/ai/pluggy/client/integration/BaseApiIntegrationTest.java @@ -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 itemsIdCreated = new ArrayList<>(); public List getItemsIdCreated() { @@ -28,9 +28,9 @@ public List 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() { @@ -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() { diff --git a/src/test/java/ai/pluggy/client/integration/CreateItemTest.java b/src/test/java/ai/pluggy/client/integration/CreateItemTest.java index 0f6d61d..09513bd 100644 --- a/src/test/java/ai/pluggy/client/integration/CreateItemTest.java +++ b/src/test/java/ai/pluggy/client/integration/CreateItemTest.java @@ -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; @@ -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 @@ -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 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()); } @@ -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 @@ -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 itemRequestWithLocalWebhookResponse = client.service() - .createItem(createItemRequestWithLocalWebhook).execute(); + .createItem(createItemRequestWithLocalWebhook).execute(); ErrorResponse localWebhookErrorResponse = client.parseError(itemRequestWithLocalWebhookResponse); assertNotNull(localWebhookErrorResponse); assertEquals(localWebhookErrorResponse.getCode(), 400); @@ -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 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 createItemRequestCall = clientWithEncryption.service().createItem(createItemRequest); + Response itemRequestResponse = createItemRequestCall.execute(); + assertSuccessful(itemRequestResponse, clientWithEncryption); + ItemResponse itemResponse1 = itemRequestResponse.body(); + + assertNotNull(itemResponse1); + assertEquals(itemResponse1.getConnector().getId(), connectorId); + + this.getItemsIdCreated().add(itemResponse1.getId()); + } } diff --git a/src/test/java/ai/pluggy/client/integration/GetAccountTest.java b/src/test/java/ai/pluggy/client/integration/GetAccountTest.java index 0835c10..63232b7 100644 --- a/src/test/java/ai/pluggy/client/integration/GetAccountTest.java +++ b/src/test/java/ai/pluggy/client/integration/GetAccountTest.java @@ -12,11 +12,9 @@ import ai.pluggy.client.response.ErrorResponse; import java.io.IOException; import lombok.SneakyThrows; -import lombok.extern.log4j.Log4j2; import org.junit.jupiter.api.Test; import retrofit2.Response; -@Log4j2 public class GetAccountTest extends BaseApiIntegrationTest { @Test diff --git a/src/test/java/ai/pluggy/client/integration/GetAccountsTest.java b/src/test/java/ai/pluggy/client/integration/GetAccountsTest.java index 59ebcd7..7b4eb32 100644 --- a/src/test/java/ai/pluggy/client/integration/GetAccountsTest.java +++ b/src/test/java/ai/pluggy/client/integration/GetAccountsTest.java @@ -14,12 +14,10 @@ import java.util.List; import java.util.Objects; import lombok.SneakyThrows; -import lombok.extern.log4j.Log4j2; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import retrofit2.Response; -@Log4j2 public class GetAccountsTest extends BaseApiIntegrationTest { private ItemResponse pluggyBankExecution; diff --git a/src/test/java/ai/pluggy/client/integration/GetConnectorsTest.java b/src/test/java/ai/pluggy/client/integration/GetConnectorsTest.java index 68af500..ff79763 100644 --- a/src/test/java/ai/pluggy/client/integration/GetConnectorsTest.java +++ b/src/test/java/ai/pluggy/client/integration/GetConnectorsTest.java @@ -7,7 +7,6 @@ import ai.pluggy.client.response.ConnectorType; import ai.pluggy.client.response.ConnectorsResponse; import java.io.IOException; -import java.util.Arrays; import java.util.Collections; import org.junit.jupiter.api.Test; diff --git a/src/test/java/ai/pluggy/client/unit/ClientParseErrorTest.java b/src/test/java/ai/pluggy/client/unit/ClientParseErrorTest.java index a27646b..92be7bc 100644 --- a/src/test/java/ai/pluggy/client/unit/ClientParseErrorTest.java +++ b/src/test/java/ai/pluggy/client/unit/ClientParseErrorTest.java @@ -2,12 +2,10 @@ import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertNotNull; -import static org.junit.jupiter.api.Assertions.assertThrows; import static org.junit.jupiter.api.Assertions.assertTrue; import ai.pluggy.client.PluggyClient; import ai.pluggy.client.response.ErrorResponse; -import com.google.gson.JsonParseException; import java.io.IOException; import okhttp3.MediaType; import okhttp3.ResponseBody;