From e87e6c86e6c308f63bd5d23e3eae1f5ca4daed78 Mon Sep 17 00:00:00 2001 From: Juan Cernadas Date: Thu, 5 Nov 2026 15:54:38 -0300 Subject: [PATCH 1/6] fix: encrypt item parameters as UTF-8 and allow body-less item updates Parameters were encoded with the JVM's default charset before encryption, so non-ASCII credentials were altered wherever that default isn't UTF-8. An item update without a body, such as updateItem(itemId), threw a NullPointerException when encryption was enabled; it is now sent as-is. --- .../auth/EncryptedParametersInterceptor.java | 13 +++++-- .../EncryptedParametersInterceptorTest.java | 37 +++++++++++++++++-- 2 files changed, 43 insertions(+), 7 deletions(-) diff --git a/src/main/java/ai/pluggy/client/auth/EncryptedParametersInterceptor.java b/src/main/java/ai/pluggy/client/auth/EncryptedParametersInterceptor.java index 0215504..559c547 100644 --- a/src/main/java/ai/pluggy/client/auth/EncryptedParametersInterceptor.java +++ b/src/main/java/ai/pluggy/client/auth/EncryptedParametersInterceptor.java @@ -3,6 +3,7 @@ import static ai.pluggy.utils.Asserts.assertNotNull; import java.io.IOException; +import java.nio.charset.StandardCharsets; import java.security.InvalidKeyException; import java.security.KeyFactory; import java.security.NoSuchAlgorithmException; @@ -35,7 +36,7 @@ public class EncryptedParametersInterceptor implements Interceptor { private String[] methods = { "PATCH", "POST" }; public EncryptedParametersInterceptor(String rsaPublicKey) { - assertNotNull(rsaPublicKey, rsaPublicKey); + assertNotNull(rsaPublicKey, "rsaPublicKey"); this.rsaPublicKey = rsaPublicKey; } @@ -51,9 +52,11 @@ public Response intercept(@NotNull Chain chain) throws IOException { JsonObject jsonBody = this.transformBodyToJsonObject(originalBody); // only item credentials are encrypted: other bodies with a "parameters" field (e.g. Smart - // Transfer preauthorizations) are sent as-is, and item requests without one (e.g. MFA) too + // Transfer preauthorizations) are sent as-is, and item requests without one (e.g. MFA, or + // an update with an empty body) too if (!Arrays.asList(methods).contains(method) - || !originalRequest.url().encodedPath().contains(path) || !jsonBody.has("parameters")) { + || !originalRequest.url().encodedPath().contains(path) + || jsonBody == null || !jsonBody.has("parameters")) { return chain.proceed(originalRequest); } @@ -85,7 +88,9 @@ private String encryptParameters(String parameters) throws IOException { PublicKey publicKey = keyFactory.generatePublic(keySpec); Cipher cipher = Cipher.getInstance("RSA/ECB/OAEPPadding"); cipher.init(Cipher.ENCRYPT_MODE, publicKey); - byte[] encryptedPayloadBytes = cipher.doFinal(parameters.getBytes()); + // explicit charset: the platform default differs between JVMs and would alter + // non-ASCII credentials before encryption + byte[] encryptedPayloadBytes = cipher.doFinal(parameters.getBytes(StandardCharsets.UTF_8)); String encryptedPayload = Base64.getEncoder().encodeToString(encryptedPayloadBytes); return encryptedPayload; diff --git a/src/test/java/ai/pluggy/client/unit/EncryptedParametersInterceptorTest.java b/src/test/java/ai/pluggy/client/unit/EncryptedParametersInterceptorTest.java index 82543f7..97caebc 100644 --- a/src/test/java/ai/pluggy/client/unit/EncryptedParametersInterceptorTest.java +++ b/src/test/java/ai/pluggy/client/unit/EncryptedParametersInterceptorTest.java @@ -7,9 +7,13 @@ import com.google.gson.JsonObject; import com.google.gson.JsonParser; import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.security.GeneralSecurityException; +import java.security.KeyPair; import java.security.KeyPairGenerator; import java.security.NoSuchAlgorithmException; import java.util.Base64; +import javax.crypto.Cipher; import okhttp3.MediaType; import okhttp3.OkHttpClient; import okhttp3.Protocol; @@ -29,14 +33,16 @@ public class EncryptedParametersInterceptorTest { private static final MediaType JSON = MediaType.parse("application/json"); private OkHttpClient httpClient; + private KeyPair keys; private String sentBody; @BeforeEach void setUp() throws NoSuchAlgorithmException { KeyPairGenerator generator = KeyPairGenerator.getInstance("RSA"); generator.initialize(2048); + keys = generator.generateKeyPair(); String publicKey = "-----BEGIN PUBLIC KEY-----\n" - + Base64.getEncoder().encodeToString(generator.generateKeyPair().getPublic().getEncoded()) + + Base64.getEncoder().encodeToString(keys.getPublic().getEncoded()) + "\n-----END PUBLIC KEY-----"; httpClient = new OkHttpClient.Builder() @@ -58,12 +64,37 @@ void setUp() throws NoSuchAlgorithmException { } private JsonObject post(String path, String body) throws IOException { + send("POST", path, body); + return JsonParser.parseString(sentBody).getAsJsonObject(); + } + + private void send(String method, String path, String body) throws IOException { Request request = new Request.Builder() .url("https://api.pluggy.ai" + path) - .post(RequestBody.create(body, JSON)) + .method(method, RequestBody.create(body, JSON)) .build(); httpClient.newCall(request).execute().close(); - return JsonParser.parseString(sentBody).getAsJsonObject(); + } + + private String decrypt(String encrypted) throws GeneralSecurityException { + Cipher cipher = Cipher.getInstance("RSA/ECB/OAEPPadding"); + cipher.init(Cipher.DECRYPT_MODE, keys.getPrivate()); + return new String(cipher.doFinal(Base64.getDecoder().decode(encrypted)), StandardCharsets.UTF_8); + } + + @Test + void createItem_nonAsciiParameters_areEncryptedAsUtf8() throws Exception { + JsonObject sent = post("/items", "{\"connectorId\":0,\"parameters\":{\"password\":\"çñ€\"}}"); + + assertEquals("{\"password\":\"çñ€\"}", decrypt(sent.get("parameters").getAsString())); + } + + @Test + void updateItemWithoutBody_isSentAsIs() throws IOException { + // Retrofit sends an empty body for a PATCH declared without @Body, like updateItem(itemId) + send("PATCH", "/items/item-1", ""); + + assertEquals("", sentBody); } @Test From f8a5456e9a5b0a9f6d7783b8d963cf9c6925a507 Mon Sep 17 00:00:00 2001 From: Juan Cernadas Date: Thu, 5 Nov 2026 15:54:38 -0300 Subject: [PATCH 2/6] fix: keep the api key interceptor usable with unreadable keys Reading a stored key's exp claim threw when the key wasn't a JWT or its payload had a non-string claim, which broke every later request on the client. Such a key is now reused until the API rejects it, then refreshed once. When the refresh fails, the original 401 or 403 is returned with its body still readable, instead of a response that was already closed. --- .../client/auth/ApiKeyAuthInterceptor.java | 53 ++++++---- .../pluggy/client/unit/ApiKeyRefreshTest.java | 99 +++++++++++++++++++ 2 files changed, 132 insertions(+), 20 deletions(-) create mode 100644 src/test/java/ai/pluggy/client/unit/ApiKeyRefreshTest.java diff --git a/src/main/java/ai/pluggy/client/auth/ApiKeyAuthInterceptor.java b/src/main/java/ai/pluggy/client/auth/ApiKeyAuthInterceptor.java index e4b102d..89b8f9d 100644 --- a/src/main/java/ai/pluggy/client/auth/ApiKeyAuthInterceptor.java +++ b/src/main/java/ai/pluggy/client/auth/ApiKeyAuthInterceptor.java @@ -2,16 +2,18 @@ import static ai.pluggy.utils.Asserts.assertNotNull; +import ai.pluggy.client.PluggyClient; import ai.pluggy.utils.Version; import com.google.gson.Gson; +import com.google.gson.JsonParser; import java.io.IOException; +import java.nio.charset.StandardCharsets; import java.util.Base64; import java.util.Date; import java.util.HashMap; import java.util.Map; -import com.google.gson.reflect.TypeToken; import okhttp3.Interceptor; import okhttp3.MediaType; import okhttp3.Request; @@ -50,22 +52,24 @@ public ApiKeyAuthInterceptor(String authUrl, String clientId, String clientSecre } /** - * Helper to check if JWT token apiKey is expired + * Reads the exp claim of a JWT apiKey. The signature isn't verified: the value only decides when + * to ask the API for a new key. * - * @param token + * @return the expiry in epoch seconds, or null if the key isn't a JWT with a numeric exp claim */ - private boolean isApiKeyExpired(String token) { - Base64.Decoder decoder = Base64.getUrlDecoder(); - String[] parts = token.split("\\."); // Splitting header, payload and signature - String jwtHeader = new String(decoder.decode(parts[1])); // extracting payload part - - Map parsedJwt = new Gson().fromJson(jwtHeader, - new TypeToken>() { - }.getType()); + @Nullable + private static Long readExpiresAt(String apiKey) { + try { + String payload = new String(Base64.getUrlDecoder().decode(apiKey.split("\\.")[1]), + StandardCharsets.UTF_8); + return JsonParser.parseString(payload).getAsJsonObject().get("exp").getAsLong(); + } catch (RuntimeException e) { + return null; + } + } - long expiresAtInSeconds = Long.parseLong(parsedJwt.get("exp")); + private static boolean hasExpired(long expiresAtInSeconds) { long currentTimeInSeconds = (long) Math.floor((double) (new Date().getTime() / 1000)); - return currentTimeInSeconds >= expiresAtInSeconds; } @@ -92,9 +96,12 @@ public Response intercept(@NotNull Chain chain) throws IOException { } String apiKey; - // No 'api key' in header -> get one from token provider. - if (tokenProvider.getApiKey() != null && !isApiKeyExpired(tokenProvider.getApiKey())) { - apiKey = tokenProvider.getApiKey(); + // No 'api key' in header -> get one from token provider. A stored key whose expiry can't be + // read is reused until the API rejects it. + String storedApiKey = tokenProvider.getApiKey(); + Long storedExpiresAt = storedApiKey == null ? null : readExpiresAt(storedApiKey); + if (storedApiKey != null && (storedExpiresAt == null || !hasExpired(storedExpiresAt))) { + apiKey = storedApiKey; logger.info("Using api key from stored tokenProvider."); } else { logger.info("Requesting new api key..."); @@ -136,14 +143,20 @@ private Response proceedWithAuthRetry(@NotNull Chain chain, Request originalRequ // auth error response, check if it was due to expired token String apiKey = originalRequest.header(X_API_KEY_HEADER); - boolean isApiKeyExpired = apiKey != null && this.isApiKeyExpired(apiKey); + Long expiresAt = apiKey == null ? null : readExpiresAt(apiKey); + // a key whose expiry can't be read may have expired too + boolean mayHaveExpired = apiKey != null && (expiresAt == null || hasExpired(expiresAt)); - if (!isApiKeyExpired) { + if (!mayHaveExpired) { // apiKey not expired, auth error is unrelated to it -> return error response return response; } - // apiKey expired, try to refresh API key, and retry original request. + // apiKey expired, try to refresh API key, and retry original request. The response must be + // closed before re-authenticating, so keep a readable copy in case the refresh fails. + Response rejection = response.newBuilder() + .body(response.peekBody(PluggyClient.ONE_MIB_BYTES)) + .build(); response.close(); logger.info("ApiKey expired, attempting to request a new one and retry original request..."); @@ -152,7 +165,7 @@ private Response proceedWithAuthRetry(@NotNull Chain chain, Request originalRequ if (newApiKey == null) { logger.info("ApiKey could not be refreshed."); - return response; + return rejection; } logger.info("ApiKey refreshed OK. Retrying original request..."); diff --git a/src/test/java/ai/pluggy/client/unit/ApiKeyRefreshTest.java b/src/test/java/ai/pluggy/client/unit/ApiKeyRefreshTest.java new file mode 100644 index 0000000..8426489 --- /dev/null +++ b/src/test/java/ai/pluggy/client/unit/ApiKeyRefreshTest.java @@ -0,0 +1,99 @@ +package ai.pluggy.client.unit; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +import ai.pluggy.client.PluggyClient; +import ai.pluggy.client.response.ConnectorsResponse; +import ai.pluggy.client.response.ErrorResponse; +import ai.pluggy.client.unit.FakePluggyServer.Reply; +import java.nio.charset.StandardCharsets; +import java.util.Arrays; +import java.util.Base64; +import java.util.concurrent.atomic.AtomicInteger; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import retrofit2.Response; + +/** + * The interceptor reads an issued key's exp claim only to decide when to ask for a new one, so a + * key it can't read must not break the client, and a failed refresh must leave the API's answer + * readable. + */ +public class ApiKeyRefreshTest { + + private static final String OK = "{\"results\":[]}"; + + private FakePluggyServer api; + private String acceptedKey; + + @BeforeEach + void setUp() throws Exception { + api = new FakePluggyServer(); + api.on("GET", "/connectors", request -> request.apiKey() != null + && request.apiKey().equals(acceptedKey) + ? Reply.json(200, OK) + : Reply.json(401, "{\"message\":\"api key expired\",\"code\":401}")); + } + + @AfterEach + void tearDown() { + api.close(); + } + + private PluggyClient client() { + return PluggyClient.builder() + .baseUrl(api.url()) + .clientIdAndSecret("client-id", "client-secret") + .build(); + } + + private static String tokenWithPayload(String payloadJson) { + return "header." + Base64.getUrlEncoder().withoutPadding() + .encodeToString(payloadJson.getBytes(StandardCharsets.UTF_8)) + ".signature"; + } + + @Test + void keyWithNonStringClaims_isReusedUntilItExpires() throws Exception { + acceptedKey = tokenWithPayload("{\"exp\":4102444800,\"scopes\":[\"read\"]}"); + api.on("POST", "/auth", request -> Reply.apiKey(acceptedKey)); + PluggyClient client = client(); + + assertEquals(200, client.service().getConnectors().execute().code()); + assertEquals(200, client.service().getConnectors().execute().code()); + + assertEquals(1, api.received("/auth").size()); + } + + @Test + void keyThatIsNotAJwt_isReusedUntilRejected_thenRefreshedOnce() throws Exception { + AtomicInteger authCalls = new AtomicInteger(); + api.on("POST", "/auth", request -> Reply.apiKey("opaque-" + authCalls.incrementAndGet())); + acceptedKey = "opaque-1"; + PluggyClient client = client(); + + assertEquals(200, client.service().getConnectors().execute().code()); + assertEquals(200, client.service().getConnectors().execute().code()); + acceptedKey = "opaque-2"; + assertEquals(200, client.service().getConnectors().execute().code()); + + assertEquals(2, authCalls.get()); + assertEquals(Arrays.asList("opaque-1", "opaque-1", "opaque-1", "opaque-2"), + api.apiKeysSentTo("/connectors")); + } + + @Test + void failedRefresh_returnsTheRejectionReadable() throws Exception { + AtomicInteger authCalls = new AtomicInteger(); + api.on("POST", "/auth", request -> authCalls.incrementAndGet() == 1 + ? Reply.apiKey(FakePluggyServer.jwt("team", 1L)) + : Reply.json(500, "{}")); + PluggyClient client = client(); + + Response response = client.service().getConnectors().execute(); + + assertEquals(401, response.code()); + ErrorResponse error = client.parseError(response); + assertEquals("api key expired", error.getMessage()); + } +} From 5eb22484e341af4a7833e5537909041fc2cc3d61 Mon Sep 17 00:00:00 2001 From: Juan Cernadas Date: Thu, 5 Nov 2026 15:54:55 -0300 Subject: [PATCH 3/6] fix: keep api keys and item credentials out of error messages and toString A successful /auth response that couldn't be parsed was copied into the exception message and the error log, and that body carries the api key. Error responses still keep their body in the message. toString on AuthResponse, ConnectTokenResponse, CreateItemRequest and UpdateItemRequest no longer prints the key, the token or the item parameters. --- .../java/ai/pluggy/client/PluggyClient.java | 7 +- .../client/auth/AuthenticationHelper.java | 6 +- .../client/request/CreateItemRequest.java | 2 + .../client/request/UpdateItemRequest.java | 2 + .../pluggy/client/response/AuthResponse.java | 2 + .../client/response/ConnectTokenResponse.java | 2 + .../client/unit/SecretsInDiagnosticsTest.java | 72 +++++++++++++++++++ 7 files changed, 89 insertions(+), 4 deletions(-) create mode 100644 src/test/java/ai/pluggy/client/unit/SecretsInDiagnosticsTest.java diff --git a/src/main/java/ai/pluggy/client/PluggyClient.java b/src/main/java/ai/pluggy/client/PluggyClient.java index aa6a6f0..2d0d721 100644 --- a/src/main/java/ai/pluggy/client/PluggyClient.java +++ b/src/main/java/ai/pluggy/client/PluggyClient.java @@ -316,8 +316,11 @@ public String authenticate() throws IOException, PluggyException { // unexpected IO exception throw e; } catch (Exception e) { - // non-successful or malformed JSON response - String pluggyResponseErrorMessage = buildResponseErrorMessage(response); + // non-successful or malformed JSON response. A successful /auth body carries the api + // key, so it is only included when the response is an error. + String pluggyResponseErrorMessage = response.isSuccessful() + ? buildResponseErrorMessage(response, null) + : buildResponseErrorMessage(response); throw new PluggyException(pluggyResponseErrorMessage, response, e); } diff --git a/src/main/java/ai/pluggy/client/auth/AuthenticationHelper.java b/src/main/java/ai/pluggy/client/auth/AuthenticationHelper.java index c082950..35dca1f 100644 --- a/src/main/java/ai/pluggy/client/auth/AuthenticationHelper.java +++ b/src/main/java/ai/pluggy/client/auth/AuthenticationHelper.java @@ -45,9 +45,11 @@ public static String extractApiKeyFromResponse(Response response) try { authResponse = Utils.parseJsonResponse(responseBodyString, AuthResponse.class); } catch (JsonSyntaxException exception) { - // response didn't contain a valid JSON body, nothing to parse + // response didn't contain a valid JSON body, nothing to parse. The body is left out of the + // message: a successful /auth response carries the api key. throw new JsonSyntaxException( - String.format("/auth response is not a valid JSON body: '%s'", responseBodyString), + String.format("/auth response is not a valid JSON body (%d characters)", + responseBodyString.length()), exception); } diff --git a/src/main/java/ai/pluggy/client/request/CreateItemRequest.java b/src/main/java/ai/pluggy/client/request/CreateItemRequest.java index 772de13..826631d 100644 --- a/src/main/java/ai/pluggy/client/request/CreateItemRequest.java +++ b/src/main/java/ai/pluggy/client/request/CreateItemRequest.java @@ -2,6 +2,7 @@ import lombok.AllArgsConstructor; import lombok.Builder; +import lombok.ToString; import lombok.Value; @Value @@ -9,6 +10,7 @@ @Builder public class CreateItemRequest { Integer connectorId; + @ToString.Exclude ParametersMap parameters; String webhookUrl; String clientUserId; diff --git a/src/main/java/ai/pluggy/client/request/UpdateItemRequest.java b/src/main/java/ai/pluggy/client/request/UpdateItemRequest.java index 2f96be8..17aa355 100644 --- a/src/main/java/ai/pluggy/client/request/UpdateItemRequest.java +++ b/src/main/java/ai/pluggy/client/request/UpdateItemRequest.java @@ -4,6 +4,7 @@ import lombok.Builder; import lombok.Data; import lombok.NoArgsConstructor; +import lombok.ToString; @Builder @Data @@ -11,6 +12,7 @@ @NoArgsConstructor public class UpdateItemRequest { Integer connectorId; + @ToString.Exclude ParametersMap parameters; String webhookUrl; String clientUserId; diff --git a/src/main/java/ai/pluggy/client/response/AuthResponse.java b/src/main/java/ai/pluggy/client/response/AuthResponse.java index b244e15..abe80fd 100644 --- a/src/main/java/ai/pluggy/client/response/AuthResponse.java +++ b/src/main/java/ai/pluggy/client/response/AuthResponse.java @@ -4,6 +4,7 @@ import lombok.Builder; import lombok.Data; import lombok.NoArgsConstructor; +import lombok.ToString; @Data @Builder @@ -11,5 +12,6 @@ @AllArgsConstructor public class AuthResponse { + @ToString.Exclude String apiKey; } diff --git a/src/main/java/ai/pluggy/client/response/ConnectTokenResponse.java b/src/main/java/ai/pluggy/client/response/ConnectTokenResponse.java index ba96668..16d5ff6 100644 --- a/src/main/java/ai/pluggy/client/response/ConnectTokenResponse.java +++ b/src/main/java/ai/pluggy/client/response/ConnectTokenResponse.java @@ -4,6 +4,7 @@ import lombok.Builder; import lombok.Data; import lombok.NoArgsConstructor; +import lombok.ToString; @Data @Builder @@ -11,5 +12,6 @@ @AllArgsConstructor public class ConnectTokenResponse { + @ToString.Exclude String accessToken; } diff --git a/src/test/java/ai/pluggy/client/unit/SecretsInDiagnosticsTest.java b/src/test/java/ai/pluggy/client/unit/SecretsInDiagnosticsTest.java new file mode 100644 index 0000000..67360a3 --- /dev/null +++ b/src/test/java/ai/pluggy/client/unit/SecretsInDiagnosticsTest.java @@ -0,0 +1,72 @@ +package ai.pluggy.client.unit; + +import static org.junit.jupiter.api.Assertions.assertFalse; +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.request.CreateItemRequest; +import ai.pluggy.client.request.ParametersMap; +import ai.pluggy.client.request.UpdateItemRequest; +import ai.pluggy.client.response.AuthResponse; +import ai.pluggy.client.response.ConnectTokenResponse; +import ai.pluggy.client.unit.FakePluggyServer.Reply; +import ai.pluggy.exception.PluggyException; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +/** + * Error messages, logs and toString output end up in places with wider access than the + * credentials themselves, so keys, tokens and item credentials must stay out of them. + */ +public class SecretsInDiagnosticsTest { + + private FakePluggyServer api; + private PluggyClient client; + + @BeforeEach + void setUp() throws Exception { + api = new FakePluggyServer(); + client = PluggyClient.builder() + .baseUrl(api.url()) + .clientIdAndSecret("client-id", "client-secret") + .build(); + } + + @AfterEach + void tearDown() { + api.close(); + } + + @Test + void authenticate_unreadableSuccessfulResponse_keepsTheBodyOutOfTheError() { + api.on("POST", "/auth", request -> Reply.json(200, "{\"apiKey\":{\"value\":\"leaked-key\"}}")); + + PluggyException error = assertThrows(PluggyException.class, client::authenticate); + + for (Throwable cause = error; cause != null; cause = cause.getCause()) { + assertFalse(String.valueOf(cause.getMessage()).contains("leaked-key"), cause.toString()); + } + } + + @Test + void authenticate_rejection_keepsTheApiErrorInTheMessage() { + api.on("POST", "/auth", request -> Reply.json(401, "{\"message\":\"invalid credentials\"}")); + + PluggyException error = assertThrows(PluggyException.class, client::authenticate); + + assertTrue(error.getMessage().contains("invalid credentials"), error.getMessage()); + } + + @Test + void toString_omitsKeysTokensAndItemCredentials() { + ParametersMap credentials = ParametersMap.map("password", "secret-password"); + + assertFalse(new AuthResponse("secret-api-key").toString().contains("secret-api-key")); + assertFalse(new ConnectTokenResponse("secret-token").toString().contains("secret-token")); + assertFalse(new CreateItemRequest(0, credentials).toString().contains("secret-password")); + assertFalse(UpdateItemRequest.builder().parameters(credentials).build().toString() + .contains("secret-password")); + } +} From a8c1dd7620a33899b8d9e2061114a501f8156511 Mon Sep 17 00:00:00 2001 From: Juan Cernadas Date: Thu, 5 Nov 2026 15:54:55 -0300 Subject: [PATCH 4/6] fix: reject a null RSA public key rsaPublicKey(null), for example from an unset environment variable, silently built a client without parameter encryption. The integration test that uses the key now skips when the key isn't configured. --- src/main/java/ai/pluggy/client/PluggyClient.java | 2 ++ .../pluggy/client/integration/CreateItemTest.java | 4 ++++ .../unit/PluggyClientBuilderValidationTest.java | 15 +++++++++++++++ 3 files changed, 21 insertions(+) create mode 100644 src/test/java/ai/pluggy/client/unit/PluggyClientBuilderValidationTest.java diff --git a/src/main/java/ai/pluggy/client/PluggyClient.java b/src/main/java/ai/pluggy/client/PluggyClient.java index 2d0d721..846a550 100644 --- a/src/main/java/ai/pluggy/client/PluggyClient.java +++ b/src/main/java/ai/pluggy/client/PluggyClient.java @@ -184,6 +184,8 @@ public PluggyClientBuilder baseUrl(String baseUrl) { } public PluggyClientBuilder rsaPublicKey(String rsaPublicKey) { + // a key read from an unset variable must not silently disable encryption + assertNotNull(rsaPublicKey, "rsaPublicKey"); this.rsaPublicKey = rsaPublicKey; return this; } diff --git a/src/test/java/ai/pluggy/client/integration/CreateItemTest.java b/src/test/java/ai/pluggy/client/integration/CreateItemTest.java index c375390..9520dd0 100644 --- a/src/test/java/ai/pluggy/client/integration/CreateItemTest.java +++ b/src/test/java/ai/pluggy/client/integration/CreateItemTest.java @@ -6,6 +6,7 @@ import static org.junit.jupiter.api.Assertions.assertNotNull; import static org.junit.jupiter.api.Assertions.assertNull; import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.junit.jupiter.api.Assumptions.assumeTrue; import ai.pluggy.client.PluggyClient; import ai.pluggy.client.request.CreateItemRequest; @@ -107,6 +108,9 @@ void createItem_withInvalidParams_responseError400() { @SneakyThrows @Test void createItem_withEncryptedParameters_responseOk() { + assumeTrue(BaseApiIntegrationTest.RSA_PUBLIC_KEY != null, + "PLUGGY_RSA_PUBLIC_KEY is not set"); + // create item params ParametersMap parametersMap = ParametersMap.map("user", "user-ok") .with("password", "password-ok"); diff --git a/src/test/java/ai/pluggy/client/unit/PluggyClientBuilderValidationTest.java b/src/test/java/ai/pluggy/client/unit/PluggyClientBuilderValidationTest.java new file mode 100644 index 0000000..9a86b40 --- /dev/null +++ b/src/test/java/ai/pluggy/client/unit/PluggyClientBuilderValidationTest.java @@ -0,0 +1,15 @@ +package ai.pluggy.client.unit; + +import static org.junit.jupiter.api.Assertions.assertThrows; + +import ai.pluggy.client.PluggyClient; +import org.junit.jupiter.api.Test; + +public class PluggyClientBuilderValidationTest { + + @Test + void rsaPublicKey_null_isRejected() { + // a key read from an unset variable would otherwise silently disable encryption + assertThrows(IllegalArgumentException.class, () -> PluggyClient.builder().rsaPublicKey(null)); + } +} From e302095376fcb6261beaebe84a075446aa6d266b Mon Sep 17 00:00:00 2001 From: Juan Cernadas Date: Thu, 5 Nov 2026 15:54:55 -0300 Subject: [PATCH 5/6] fix: stop shipping an empty Log4j configuration The jar included an empty log4j2.properties. Log4j looks for that file before log4j2.xml, yaml or json, so it could replace an application's own logging configuration. --- src/main/resources/log4j2.properties | 0 .../client/unit/PackagedResourcesTest.java | 16 ++++++++++++++++ 2 files changed, 16 insertions(+) delete mode 100644 src/main/resources/log4j2.properties create mode 100644 src/test/java/ai/pluggy/client/unit/PackagedResourcesTest.java diff --git a/src/main/resources/log4j2.properties b/src/main/resources/log4j2.properties deleted file mode 100644 index e69de29..0000000 diff --git a/src/test/java/ai/pluggy/client/unit/PackagedResourcesTest.java b/src/test/java/ai/pluggy/client/unit/PackagedResourcesTest.java new file mode 100644 index 0000000..5903e1c --- /dev/null +++ b/src/test/java/ai/pluggy/client/unit/PackagedResourcesTest.java @@ -0,0 +1,16 @@ +package ai.pluggy.client.unit; + +import static org.junit.jupiter.api.Assertions.assertNull; + +import ai.pluggy.client.PluggyClient; +import org.junit.jupiter.api.Test; + +public class PackagedResourcesTest { + + @Test + void sdkShipsNoLog4jConfiguration() { + // Log4j looks for log4j2.properties before an application's log4j2.xml, so a file in the + // SDK jar, even an empty one, would replace the application's logging configuration + assertNull(PluggyClient.class.getResource("/log4j2.properties")); + } +} From 9f11078c860cee2adca2aa4638364e68fd1bbdcb Mon Sep 17 00:00:00 2001 From: Juan Cernadas Date: Thu, 5 Nov 2026 20:26:49 -0300 Subject: [PATCH 6/6] chore(release): bump version to 1.17.3 Also documents how the api key interceptor handles keys it can't read, and that the encryption integration test is skipped without an RSA key. --- CLAUDE.md | 4 ++-- pom.xml | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 55400e1..a87dbc7 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -42,7 +42,7 @@ Integration tests require the following env vars. In CI they are repository secr - `PLUGGY_CLIENT_ID` - `PLUGGY_CLIENT_SECRET` - `PLUGGY_BASE_URL` -- `PLUGGY_RSA_PUBLIC_KEY` (optional; only needed when exercising `EncryptedParametersInterceptor`. Without it, `CreateItemTest#createItem_withEncryptedParameters_responseOk` still passes but sends the parameters unencrypted) +- `PLUGGY_RSA_PUBLIC_KEY` (optional; only needed when exercising `EncryptedParametersInterceptor`. Without it, `CreateItemTest#createItem_withEncryptedParameters_responseOk` is skipped, since `rsaPublicKey(null)` throws instead of silently disabling encryption) Missing any of the first three causes `BaseApiIntegrationTest` to fail-fast in `@BeforeEach`. Each test that creates items appends the new id to `itemsIdCreated`; the base class's `@AfterEach` deletes them, so integration tests are responsible for tracking what they create. @@ -62,7 +62,7 @@ The payments surface is Smart Transfers (`/smart-transfers/*`) and Pix por biome **3. Two interceptors handle cross-cutting concerns** -- `ApiKeyAuthInterceptor` (`client/auth/`): transparently fetches the `x-api-key` JWT via `POST /auth` on first use, caches it in `TokenProvider`, decodes the JWT `exp` claim to detect expiry, and on a 401/403 with an expired key refreshes once and retries the original request. An `x-api-key` already on the request is the caller's credential: it is sent as-is and never refreshed or replaced with the team key. Also sets `User-Agent: PluggyJava/ (Java )` via `ai.pluggy.utils.Version`, which reads `project.version` from the filtered `pluggy.properties`, so bumping `pom.xml` is all a release needs. +- `ApiKeyAuthInterceptor` (`client/auth/`): transparently fetches the `x-api-key` JWT via `POST /auth` on first use, caches it in `TokenProvider`, decodes the JWT `exp` claim to detect expiry, and on a 401/403 with an expired key refreshes once and retries the original request. An `x-api-key` already on the request is the caller's credential: it is sent as-is and never refreshed or replaced with the team key. An issued key whose `exp` can't be read is reused until the API rejects it, then refreshed once; if the refresh fails, the original 401/403 is returned with its body still readable. Also sets `User-Agent: PluggyJava/ (Java )` via `ai.pluggy.utils.Version`, which reads `project.version` from the filtered `pluggy.properties`, so bumping `pom.xml` is all a release needs. - `EncryptedParametersInterceptor` (`client/auth/`): only attached when `rsaPublicKey(...)` is set on the builder. RSA/ECB/OAEPPadding-encrypts the `parameters` JSON field on POST/PATCH `/items` requests before they leave OkHttp. All three conditions are required: Smart Transfer preauthorizations also send a `parameters` field, which must go out as plain JSON, and item requests without one (MFA) must pass through. ### JSON / Gson conventions — important gotcha diff --git a/pom.xml b/pom.xml index 157a93e..03d7533 100644 --- a/pom.xml +++ b/pom.xml @@ -4,7 +4,7 @@ ai.pluggy pluggy-java - 1.17.2 + 1.17.3 jar