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 diff --git a/src/main/java/ai/pluggy/client/PluggyClient.java b/src/main/java/ai/pluggy/client/PluggyClient.java index aa6a6f0..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; } @@ -316,8 +318,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/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/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/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/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/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/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/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()); + } +} 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 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")); + } +} 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)); + } +} 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")); + } +}